diff --git a/src/authorizer/__init__.py b/src/authorizer/__init__.py index 148cde6..554bca5 100644 --- a/src/authorizer/__init__.py +++ b/src/authorizer/__init__.py @@ -8,7 +8,16 @@ from .client import AuthorizerClient from .exceptions import AuthorizerConnectionError, AuthorizerError from .types import ( + CLIENT_ASSERTION_TYPE_JWT_BEARER, + GRANT_TYPE_AUTHORIZATION_CODE, + GRANT_TYPE_CLIENT_CREDENTIALS, + GRANT_TYPE_REFRESH_TOKEN, + GRANT_TYPE_TOKEN_EXCHANGE, + TOKEN_TYPE_ACCESS_TOKEN, + TOKEN_TYPE_JWT, AddEmailTemplateRequest, + AddOrgMemberRequest, + AddTrustedIssuerRequest, AddWebhookRequest, AdminLoginRequest, AdminMeta, @@ -18,6 +27,16 @@ AuthToken, CheckPermissionsRequest, CheckPermissionsResponse, + Client, + ClientRequest, + ClientsResponse, + CreateClientRequest, + CreateClientResponse, + CreateOrganizationRequest, + CreateOrgOIDCConnectionRequest, + CreateOrgSAMLConnectionRequest, + CreateScimEndpointRequest, + CreateScimEndpointResponse, DeleteEmailTemplateRequest, DeleteUserRequest, EmailTemplate, @@ -44,32 +63,56 @@ InviteMembersRequest, InviteMembersResponse, ListAuditLogRequest, + ListClientsRequest, + ListOrganizationsRequest, + ListOrgMembersRequest, ListPermissionsRequest, ListPermissionsResponse, + ListTrustedIssuersRequest, ListWebhookLogRequest, LoginRequest, MagicLinkLoginRequest, MetaData, OAuthProviders, + Organization, + OrganizationRequest, + OrganizationsResponse, + OrgMember, + OrgMembersResponse, + OrgOIDCConnection, + OrgOIDCConnectionRequest, + OrgSAMLConnection, + OrgSAMLConnectionRequest, PaginatedRequest, Pagination, PaginationRequest, Permission, PermissionCheckInput, PermissionCheckResult, + RemoveOrgMemberRequest, ResendOTPRequest, ResendVerifyEmailRequest, ResetPasswordRequest, ResponseTypes, RevokeTokenRequest, + ScimEndpoint, + ScimEndpointRequest, SessionQueryRequest, SignUpRequest, TestEndpointRequest, TestEndpointResponse, TokenType, + TrustedIssuer, + TrustedIssuerRequest, + TrustedIssuersResponse, UpdateAccessRequest, + UpdateClientRequest, UpdateEmailTemplateRequest, + UpdateOrganizationRequest, + UpdateOrgOIDCConnectionRequest, + UpdateOrgSAMLConnectionRequest, UpdateProfileRequest, + UpdateTrustedIssuerRequest, UpdateUserRequest, UpdateWebhookRequest, User, @@ -99,6 +142,8 @@ "AuthorizerError", "AuthorizerConnectionError", "AddEmailTemplateRequest", + "AddOrgMemberRequest", + "AddTrustedIssuerRequest", "AddWebhookRequest", "AdminLoginRequest", "AdminMeta", @@ -108,6 +153,17 @@ "AuthToken", "CheckPermissionsRequest", "CheckPermissionsResponse", + "Client", + "CLIENT_ASSERTION_TYPE_JWT_BEARER", + "ClientRequest", + "ClientsResponse", + "CreateClientRequest", + "CreateClientResponse", + "CreateOrganizationRequest", + "CreateOrgOIDCConnectionRequest", + "CreateOrgSAMLConnectionRequest", + "CreateScimEndpointRequest", + "CreateScimEndpointResponse", "DeleteEmailTemplateRequest", "DeleteUserRequest", "EmailTemplate", @@ -131,35 +187,65 @@ "GetTokenRequest", "GetTokenResponse", "GetUserRequest", + "GRANT_TYPE_AUTHORIZATION_CODE", + "GRANT_TYPE_CLIENT_CREDENTIALS", + "GRANT_TYPE_REFRESH_TOKEN", + "GRANT_TYPE_TOKEN_EXCHANGE", "InviteMembersRequest", "InviteMembersResponse", "ListAuditLogRequest", + "ListClientsRequest", + "ListOrganizationsRequest", + "ListOrgMembersRequest", "ListPermissionsRequest", "ListPermissionsResponse", + "ListTrustedIssuersRequest", "ListWebhookLogRequest", "LoginRequest", "MagicLinkLoginRequest", "MetaData", "OAuthProviders", - "Pagination", + "Organization", + "OrganizationRequest", + "OrganizationsResponse", + "OrgMember", + "OrgMembersResponse", + "OrgOIDCConnection", + "OrgOIDCConnectionRequest", + "OrgSAMLConnection", + "OrgSAMLConnectionRequest", "PaginatedRequest", + "Pagination", "PaginationRequest", "Permission", "PermissionCheckInput", "PermissionCheckResult", + "RemoveOrgMemberRequest", "ResendOTPRequest", "ResendVerifyEmailRequest", "ResetPasswordRequest", "ResponseTypes", "RevokeTokenRequest", + "ScimEndpoint", + "ScimEndpointRequest", "SessionQueryRequest", "SignUpRequest", "TestEndpointRequest", "TestEndpointResponse", + "TOKEN_TYPE_ACCESS_TOKEN", + "TOKEN_TYPE_JWT", "TokenType", + "TrustedIssuer", + "TrustedIssuerRequest", + "TrustedIssuersResponse", "UpdateAccessRequest", + "UpdateClientRequest", "UpdateEmailTemplateRequest", + "UpdateOrganizationRequest", + "UpdateOrgOIDCConnectionRequest", + "UpdateOrgSAMLConnectionRequest", "UpdateProfileRequest", + "UpdateTrustedIssuerRequest", "UpdateUserRequest", "UpdateWebhookRequest", "User", diff --git a/src/authorizer/_core.py b/src/authorizer/_core.py index 6a3550e..18c2f42 100644 --- a/src/authorizer/_core.py +++ b/src/authorizer/_core.py @@ -8,6 +8,7 @@ from urllib.parse import urlparse from .exceptions import AuthorizerError +from .types import GRANT_TYPE_TOKEN_EXCHANGE # Supported transport protocols. ``graphql`` is the default (100% backward # compatible). ``rest`` maps to the public/admin proto google.api.http paths; @@ -87,6 +88,47 @@ def build_oauth_request( return RequestSpec("POST", f"{authorizer_url}{path}", headers, body) +# Optional /oauth/token parameters, sent only when set. Covers refresh_token, +# client_credentials (RFC 6749 §4.4), RFC 7523 client_assertion, and RFC 8693 +# token exchange (+ RFC 8707 resource). +_TOKEN_OPTIONAL_PARAMS = ( + "refresh_token", + "client_secret", + "scope", + "client_assertion", + "client_assertion_type", + "subject_token", + "subject_token_type", + "actor_token", + "actor_token_type", + "resource", +) + +def build_token_body(client_id: str, req: Any) -> dict[str, str]: + """Build the /oauth/token form body from a GetTokenRequest. + + Only set parameters are sent. The body MUST be form-encoded on the wire: + the server reads the RFC 8707 ``resource`` parameter from the POST form + (to reject repeated values), so a JSON body would drop it. + """ + grant_type = req.grant_type or "authorization_code" + if grant_type == "refresh_token" and not (req.refresh_token and req.refresh_token.strip()): + raise ValueError("refresh_token is required for refresh_token grant") + if grant_type == GRANT_TYPE_TOKEN_EXCHANGE and not ( + req.subject_token and req.subject_token.strip() + ): + raise ValueError("subject_token is required for token exchange grant") + body: dict[str, str] = {"client_id": client_id, "grant_type": grant_type} + if grant_type == "authorization_code": + body["code"] = req.code or "" + body["code_verifier"] = req.code_verifier or "" + for key in _TOKEN_OPTIONAL_PARAMS: + value = getattr(req, key) + if value: + body[key] = value + return body + + def prepare_http( config: ClientConfig, spec: Any, diff --git a/src/authorizer/_dispatch.py b/src/authorizer/_dispatch.py index ff7c8bd..3025fc9 100644 --- a/src/authorizer/_dispatch.py +++ b/src/authorizer/_dispatch.py @@ -267,4 +267,71 @@ class MethodSpec: "admin_signup": MethodSpec(GQL_ONLY, q.ADMIN_SIGNUP, "_admin_signup"), "update_env": MethodSpec(GQL_ONLY, q.ADMIN_UPDATE_ENV, "_update_env"), "generate_jwt_keys": MethodSpec(GQL_ONLY, q.ADMIN_GENERATE_JWT_KEYS, "_generate_jwt_keys"), + # Machine-agent-identity ops. Orgs/SSO/SCIM are graphql-only on the server. + # Clients + trusted issuers DO have proto RPCs server-side, but the vendored + # stubs (src/authorizer/_grpc) predate them — rest/grpc stay off until the + # stubs are re-vendored, so all of these are graphql-only for now. + "create_client": MethodSpec(GQL_ONLY, q.ADMIN_CREATE_CLIENT, "_create_client"), + "update_client": MethodSpec(GQL_ONLY, q.ADMIN_UPDATE_CLIENT, "_update_client"), + "delete_client": MethodSpec(GQL_ONLY, q.ADMIN_DELETE_CLIENT, "_delete_client"), + "rotate_client_secret": MethodSpec( + GQL_ONLY, q.ADMIN_ROTATE_CLIENT_SECRET, "_rotate_client_secret" + ), + "get_client": MethodSpec(GQL_ONLY, q.ADMIN_GET_CLIENT, "_client"), + "clients": MethodSpec(GQL_ONLY, q.ADMIN_CLIENTS, "_clients"), + "add_trusted_issuer": MethodSpec(GQL_ONLY, q.ADMIN_ADD_TRUSTED_ISSUER, "_add_trusted_issuer"), + "update_trusted_issuer": MethodSpec( + GQL_ONLY, q.ADMIN_UPDATE_TRUSTED_ISSUER, "_update_trusted_issuer" + ), + "delete_trusted_issuer": MethodSpec( + GQL_ONLY, q.ADMIN_DELETE_TRUSTED_ISSUER, "_delete_trusted_issuer" + ), + "get_trusted_issuer": MethodSpec(GQL_ONLY, q.ADMIN_GET_TRUSTED_ISSUER, "_trusted_issuer"), + "trusted_issuers": MethodSpec(GQL_ONLY, q.ADMIN_TRUSTED_ISSUERS, "_trusted_issuers"), + "create_organization": MethodSpec( + GQL_ONLY, q.ADMIN_CREATE_ORGANIZATION, "_create_organization" + ), + "update_organization": MethodSpec( + GQL_ONLY, q.ADMIN_UPDATE_ORGANIZATION, "_update_organization" + ), + "delete_organization": MethodSpec( + GQL_ONLY, q.ADMIN_DELETE_ORGANIZATION, "_delete_organization" + ), + "add_org_member": MethodSpec(GQL_ONLY, q.ADMIN_ADD_ORG_MEMBER, "_add_org_member"), + "remove_org_member": MethodSpec(GQL_ONLY, q.ADMIN_REMOVE_ORG_MEMBER, "_remove_org_member"), + "get_organization": MethodSpec(GQL_ONLY, q.ADMIN_GET_ORGANIZATION, "_organization"), + "organizations": MethodSpec(GQL_ONLY, q.ADMIN_ORGANIZATIONS, "_organizations"), + "org_members": MethodSpec(GQL_ONLY, q.ADMIN_ORG_MEMBERS, "_org_members"), + "create_org_oidc_connection": MethodSpec( + GQL_ONLY, q.ADMIN_CREATE_ORG_OIDC_CONNECTION, "_create_org_oidc_connection" + ), + "update_org_oidc_connection": MethodSpec( + GQL_ONLY, q.ADMIN_UPDATE_ORG_OIDC_CONNECTION, "_update_org_oidc_connection" + ), + "delete_org_oidc_connection": MethodSpec( + GQL_ONLY, q.ADMIN_DELETE_ORG_OIDC_CONNECTION, "_delete_org_oidc_connection" + ), + "get_org_oidc_connection": MethodSpec( + GQL_ONLY, q.ADMIN_GET_ORG_OIDC_CONNECTION, "_org_oidc_connection" + ), + "create_org_saml_connection": MethodSpec( + GQL_ONLY, q.ADMIN_CREATE_ORG_SAML_CONNECTION, "_create_org_saml_connection" + ), + "update_org_saml_connection": MethodSpec( + GQL_ONLY, q.ADMIN_UPDATE_ORG_SAML_CONNECTION, "_update_org_saml_connection" + ), + "delete_org_saml_connection": MethodSpec( + GQL_ONLY, q.ADMIN_DELETE_ORG_SAML_CONNECTION, "_delete_org_saml_connection" + ), + "get_org_saml_connection": MethodSpec( + GQL_ONLY, q.ADMIN_GET_ORG_SAML_CONNECTION, "_org_saml_connection" + ), + "create_scim_endpoint": MethodSpec( + GQL_ONLY, q.ADMIN_CREATE_SCIM_ENDPOINT, "_create_scim_endpoint" + ), + "rotate_scim_token": MethodSpec(GQL_ONLY, q.ADMIN_ROTATE_SCIM_TOKEN, "_rotate_scim_token"), + "delete_scim_endpoint": MethodSpec( + GQL_ONLY, q.ADMIN_DELETE_SCIM_ENDPOINT, "_delete_scim_endpoint" + ), + "get_scim_endpoint": MethodSpec(GQL_ONLY, q.ADMIN_GET_SCIM_ENDPOINT, "_scim_endpoint"), } diff --git a/src/authorizer/_queries.py b/src/authorizer/_queries.py index dbdc8cf..1c68a95 100644 --- a/src/authorizer/_queries.py +++ b/src/authorizer/_queries.py @@ -252,3 +252,156 @@ "query adminFgaExpand($data: FgaExpandInput!) " "{ _fga_expand(params: $data) { tree } }" ) + +# --------------------------------------------------------------------------- # +# Machine-agent-identity admin ops: clients (service accounts), trusted +# issuers, organizations, org SSO connections, SCIM endpoints. +# --------------------------------------------------------------------------- # +CLIENT_FRAGMENT = "id name description allowed_scopes is_active created_at updated_at" +TRUSTED_ISSUER_FRAGMENT = ( + "id service_account_id name issuer_url key_source_type jwks_url expected_aud " + "subject_claim allowed_subjects issuer_type is_active spiffe_refresh_hint_seconds " + "created_at updated_at" +) +ORGANIZATION_FRAGMENT = "id name display_name enabled created_at updated_at" +ORG_MEMBER_FRAGMENT = "id org_id user_id roles created_at updated_at" +ORG_OIDC_CONNECTION_FRAGMENT = ( + "id org_id name issuer_url sso_client_id scopes redirect_uri is_active " + "created_at updated_at" +) +ORG_SAML_CONNECTION_FRAGMENT = ( + "id org_id name idp_entity_id idp_sso_url sp_entity_id acs_url attribute_mapping " + "allow_idp_initiated is_active created_at updated_at" +) +SCIM_ENDPOINT_FRAGMENT = "id org_id enabled created_at updated_at" + +ADMIN_CREATE_CLIENT = ( + "mutation adminCreateClient($data: CreateClientRequest!) " + f"{{ _create_client(params: $data) {{ client {{ {CLIENT_FRAGMENT} }} client_secret }} }}" +) +ADMIN_UPDATE_CLIENT = ( + "mutation adminUpdateClient($data: UpdateClientRequest!) " + f"{{ _update_client(params: $data) {{ {CLIENT_FRAGMENT} }} }}" +) +ADMIN_DELETE_CLIENT = ( + "mutation adminDeleteClient($data: ClientRequest!) " + "{ _delete_client(params: $data) { message } }" +) +ADMIN_ROTATE_CLIENT_SECRET = ( + "mutation adminRotateClientSecret($data: ClientRequest!) " + f"{{ _rotate_client_secret(params: $data) " + f"{{ client {{ {CLIENT_FRAGMENT} }} client_secret }} }}" +) +ADMIN_GET_CLIENT = ( + "query adminClient($data: ClientRequest!) " + f"{{ _client(params: $data) {{ {CLIENT_FRAGMENT} }} }}" +) +ADMIN_CLIENTS = ( + "query adminClients($data: ListClientsRequest) " + f"{{ _clients(params: $data) {{ {PAGINATION_FRAGMENT} clients {{ {CLIENT_FRAGMENT} }} }} }}" +) +ADMIN_ADD_TRUSTED_ISSUER = ( + "mutation adminAddTrustedIssuer($data: AddTrustedIssuerRequest!) " + f"{{ _add_trusted_issuer(params: $data) {{ {TRUSTED_ISSUER_FRAGMENT} }} }}" +) +ADMIN_UPDATE_TRUSTED_ISSUER = ( + "mutation adminUpdateTrustedIssuer($data: UpdateTrustedIssuerRequest!) " + f"{{ _update_trusted_issuer(params: $data) {{ {TRUSTED_ISSUER_FRAGMENT} }} }}" +) +ADMIN_DELETE_TRUSTED_ISSUER = ( + "mutation adminDeleteTrustedIssuer($data: TrustedIssuerRequest!) " + "{ _delete_trusted_issuer(params: $data) { message } }" +) +ADMIN_GET_TRUSTED_ISSUER = ( + "query adminTrustedIssuer($data: TrustedIssuerRequest!) " + f"{{ _trusted_issuer(params: $data) {{ {TRUSTED_ISSUER_FRAGMENT} }} }}" +) +ADMIN_TRUSTED_ISSUERS = ( + "query adminTrustedIssuers($data: ListTrustedIssuersRequest) " + f"{{ _trusted_issuers(params: $data) {{ {PAGINATION_FRAGMENT} " + f"trusted_issuers {{ {TRUSTED_ISSUER_FRAGMENT} }} }} }}" +) +ADMIN_CREATE_ORGANIZATION = ( + "mutation adminCreateOrganization($data: CreateOrganizationRequest!) " + f"{{ _create_organization(params: $data) {{ {ORGANIZATION_FRAGMENT} }} }}" +) +ADMIN_UPDATE_ORGANIZATION = ( + "mutation adminUpdateOrganization($data: UpdateOrganizationRequest!) " + f"{{ _update_organization(params: $data) {{ {ORGANIZATION_FRAGMENT} }} }}" +) +ADMIN_DELETE_ORGANIZATION = ( + "mutation adminDeleteOrganization($data: OrganizationRequest!) " + "{ _delete_organization(params: $data) { message } }" +) +ADMIN_ADD_ORG_MEMBER = ( + "mutation adminAddOrgMember($data: AddOrgMemberRequest!) " + f"{{ _add_org_member(params: $data) {{ {ORG_MEMBER_FRAGMENT} }} }}" +) +ADMIN_REMOVE_ORG_MEMBER = ( + "mutation adminRemoveOrgMember($data: RemoveOrgMemberRequest!) " + "{ _remove_org_member(params: $data) { message } }" +) +ADMIN_GET_ORGANIZATION = ( + "query adminOrganization($data: OrganizationRequest!) " + f"{{ _organization(params: $data) {{ {ORGANIZATION_FRAGMENT} }} }}" +) +ADMIN_ORGANIZATIONS = ( + "query adminOrganizations($data: ListOrganizationsRequest) " + f"{{ _organizations(params: $data) {{ {PAGINATION_FRAGMENT} " + f"organizations {{ {ORGANIZATION_FRAGMENT} }} }} }}" +) +ADMIN_ORG_MEMBERS = ( + "query adminOrgMembers($data: ListOrgMembersRequest!) " + f"{{ _org_members(params: $data) {{ {PAGINATION_FRAGMENT} " + f"org_members {{ {ORG_MEMBER_FRAGMENT} }} }} }}" +) +ADMIN_CREATE_ORG_OIDC_CONNECTION = ( + "mutation adminCreateOrgOIDCConnection($data: CreateOrgOIDCConnectionRequest!) " + f"{{ _create_org_oidc_connection(params: $data) {{ {ORG_OIDC_CONNECTION_FRAGMENT} }} }}" +) +ADMIN_UPDATE_ORG_OIDC_CONNECTION = ( + "mutation adminUpdateOrgOIDCConnection($data: UpdateOrgOIDCConnectionRequest!) " + f"{{ _update_org_oidc_connection(params: $data) {{ {ORG_OIDC_CONNECTION_FRAGMENT} }} }}" +) +ADMIN_DELETE_ORG_OIDC_CONNECTION = ( + "mutation adminDeleteOrgOIDCConnection($data: OrgOIDCConnectionRequest!) " + "{ _delete_org_oidc_connection(params: $data) { message } }" +) +ADMIN_GET_ORG_OIDC_CONNECTION = ( + "query adminOrgOIDCConnection($data: OrgOIDCConnectionRequest!) " + f"{{ _org_oidc_connection(params: $data) {{ {ORG_OIDC_CONNECTION_FRAGMENT} }} }}" +) +ADMIN_CREATE_ORG_SAML_CONNECTION = ( + "mutation adminCreateOrgSAMLConnection($data: CreateOrgSAMLConnectionRequest!) " + f"{{ _create_org_saml_connection(params: $data) {{ {ORG_SAML_CONNECTION_FRAGMENT} }} }}" +) +ADMIN_UPDATE_ORG_SAML_CONNECTION = ( + "mutation adminUpdateOrgSAMLConnection($data: UpdateOrgSAMLConnectionRequest!) " + f"{{ _update_org_saml_connection(params: $data) {{ {ORG_SAML_CONNECTION_FRAGMENT} }} }}" +) +ADMIN_DELETE_ORG_SAML_CONNECTION = ( + "mutation adminDeleteOrgSAMLConnection($data: OrgSAMLConnectionRequest!) " + "{ _delete_org_saml_connection(params: $data) { message } }" +) +ADMIN_GET_ORG_SAML_CONNECTION = ( + "query adminOrgSAMLConnection($data: OrgSAMLConnectionRequest!) " + f"{{ _org_saml_connection(params: $data) {{ {ORG_SAML_CONNECTION_FRAGMENT} }} }}" +) +ADMIN_CREATE_SCIM_ENDPOINT = ( + "mutation adminCreateScimEndpoint($data: CreateScimEndpointRequest!) " + f"{{ _create_scim_endpoint(params: $data) " + f"{{ scim_endpoint {{ {SCIM_ENDPOINT_FRAGMENT} }} token }} }}" +) +ADMIN_ROTATE_SCIM_TOKEN = ( + "mutation adminRotateScimToken($data: ScimEndpointRequest!) " + f"{{ _rotate_scim_token(params: $data) " + f"{{ scim_endpoint {{ {SCIM_ENDPOINT_FRAGMENT} }} token }} }}" +) +ADMIN_DELETE_SCIM_ENDPOINT = ( + "mutation adminDeleteScimEndpoint($data: ScimEndpointRequest!) " + "{ _delete_scim_endpoint(params: $data) { message } }" +) +ADMIN_GET_SCIM_ENDPOINT = ( + "query adminScimEndpoint($data: ScimEndpointRequest!) " + f"{{ _scim_endpoint(params: $data) {{ {SCIM_ENDPOINT_FRAGMENT} }} }}" +) diff --git a/src/authorizer/admin_client.py b/src/authorizer/admin_client.py index ae39f5c..f36db82 100644 --- a/src/authorizer/admin_client.py +++ b/src/authorizer/admin_client.py @@ -267,3 +267,180 @@ def fga_reset(self) -> t.GenericResponse: """Destructive: deletes the entire fine-grained authorization store.""" res = self._invoke("fga_reset", d.ADMIN["fga_reset"], None) return t.GenericResponse.from_dict(res or {}) + + # -- clients (service accounts / machine identities) ------------------- # + def create_client(self, req: t.CreateClientRequest) -> t.CreateClientResponse: + """The returned client_secret is shown ONCE and can never be retrieved again.""" + res = self._invoke("create_client", d.ADMIN["create_client"], req.to_dict()) + return t.CreateClientResponse.from_dict(res or {}) + + def update_client(self, req: t.UpdateClientRequest) -> t.Client: + res = self._invoke("update_client", d.ADMIN["update_client"], req.to_dict()) + return t.Client.from_dict(res or {}) + + def delete_client(self, req: t.ClientRequest) -> t.GenericResponse: + """Destructive: permanently deletes the client; its tokens stop resolving.""" + res = self._invoke("delete_client", d.ADMIN["delete_client"], req.to_dict()) + return t.GenericResponse.from_dict(res or {}) + + def rotate_client_secret(self, req: t.ClientRequest) -> t.CreateClientResponse: + """Destructive: invalidates the old secret; the new one is shown ONCE.""" + res = self._invoke("rotate_client_secret", d.ADMIN["rotate_client_secret"], req.to_dict()) + return t.CreateClientResponse.from_dict(res or {}) + + def get_client(self, req: t.ClientRequest) -> t.Client: + res = self._invoke("get_client", d.ADMIN["get_client"], req.to_dict()) + return t.Client.from_dict(res or {}) + + def clients(self, req: t.ListClientsRequest | None = None) -> t.ClientsResponse: + res = self._invoke("clients", d.ADMIN["clients"], req.to_dict() if req else None) + return t.ClientsResponse.from_dict(res or {}) + + # -- trusted issuers ---------------------------------------------------- # + def add_trusted_issuer(self, req: t.AddTrustedIssuerRequest) -> t.TrustedIssuer: + res = self._invoke("add_trusted_issuer", d.ADMIN["add_trusted_issuer"], req.to_dict()) + return t.TrustedIssuer.from_dict(res or {}) + + def update_trusted_issuer(self, req: t.UpdateTrustedIssuerRequest) -> t.TrustedIssuer: + res = self._invoke( + "update_trusted_issuer", d.ADMIN["update_trusted_issuer"], req.to_dict() + ) + return t.TrustedIssuer.from_dict(res or {}) + + def delete_trusted_issuer(self, req: t.TrustedIssuerRequest) -> t.GenericResponse: + """Destructive: tokens from this issuer stop authenticating.""" + res = self._invoke( + "delete_trusted_issuer", d.ADMIN["delete_trusted_issuer"], req.to_dict() + ) + return t.GenericResponse.from_dict(res or {}) + + def get_trusted_issuer(self, req: t.TrustedIssuerRequest) -> t.TrustedIssuer: + res = self._invoke("get_trusted_issuer", d.ADMIN["get_trusted_issuer"], req.to_dict()) + return t.TrustedIssuer.from_dict(res or {}) + + def trusted_issuers( + self, req: t.ListTrustedIssuersRequest | None = None + ) -> t.TrustedIssuersResponse: + res = self._invoke( + "trusted_issuers", d.ADMIN["trusted_issuers"], req.to_dict() if req else None + ) + return t.TrustedIssuersResponse.from_dict(res or {}) + + # -- organizations ------------------------------------------------------ # + def create_organization(self, req: t.CreateOrganizationRequest) -> t.Organization: + res = self._invoke("create_organization", d.ADMIN["create_organization"], req.to_dict()) + return t.Organization.from_dict(res or {}) + + def update_organization(self, req: t.UpdateOrganizationRequest) -> t.Organization: + res = self._invoke("update_organization", d.ADMIN["update_organization"], req.to_dict()) + return t.Organization.from_dict(res or {}) + + def delete_organization(self, req: t.OrganizationRequest) -> t.GenericResponse: + """Destructive: permanently deletes the organization.""" + res = self._invoke("delete_organization", d.ADMIN["delete_organization"], req.to_dict()) + return t.GenericResponse.from_dict(res or {}) + + def add_org_member(self, req: t.AddOrgMemberRequest) -> t.OrgMember: + res = self._invoke("add_org_member", d.ADMIN["add_org_member"], req.to_dict()) + return t.OrgMember.from_dict(res or {}) + + def remove_org_member(self, req: t.RemoveOrgMemberRequest) -> t.GenericResponse: + res = self._invoke("remove_org_member", d.ADMIN["remove_org_member"], req.to_dict()) + return t.GenericResponse.from_dict(res or {}) + + def get_organization(self, req: t.OrganizationRequest) -> t.Organization: + res = self._invoke("get_organization", d.ADMIN["get_organization"], req.to_dict()) + return t.Organization.from_dict(res or {}) + + def organizations( + self, req: t.ListOrganizationsRequest | None = None + ) -> t.OrganizationsResponse: + res = self._invoke( + "organizations", d.ADMIN["organizations"], req.to_dict() if req else None + ) + return t.OrganizationsResponse.from_dict(res or {}) + + def org_members(self, req: t.ListOrgMembersRequest) -> t.OrgMembersResponse: + res = self._invoke("org_members", d.ADMIN["org_members"], req.to_dict()) + return t.OrgMembersResponse.from_dict(res or {}) + + # -- org SSO connections ------------------------------------------------ # + def create_org_oidc_connection( + self, req: t.CreateOrgOIDCConnectionRequest + ) -> t.OrgOIDCConnection: + res = self._invoke( + "create_org_oidc_connection", d.ADMIN["create_org_oidc_connection"], req.to_dict() + ) + return t.OrgOIDCConnection.from_dict(res or {}) + + def update_org_oidc_connection( + self, req: t.UpdateOrgOIDCConnectionRequest + ) -> t.OrgOIDCConnection: + res = self._invoke( + "update_org_oidc_connection", d.ADMIN["update_org_oidc_connection"], req.to_dict() + ) + return t.OrgOIDCConnection.from_dict(res or {}) + + def delete_org_oidc_connection(self, req: t.OrgOIDCConnectionRequest) -> t.GenericResponse: + """Destructive: org members lose this OIDC SSO path.""" + res = self._invoke( + "delete_org_oidc_connection", d.ADMIN["delete_org_oidc_connection"], req.to_dict() + ) + return t.GenericResponse.from_dict(res or {}) + + def get_org_oidc_connection(self, req: t.OrgOIDCConnectionRequest) -> t.OrgOIDCConnection: + res = self._invoke( + "get_org_oidc_connection", d.ADMIN["get_org_oidc_connection"], req.to_dict() + ) + return t.OrgOIDCConnection.from_dict(res or {}) + + def create_org_saml_connection( + self, req: t.CreateOrgSAMLConnectionRequest + ) -> t.OrgSAMLConnection: + res = self._invoke( + "create_org_saml_connection", d.ADMIN["create_org_saml_connection"], req.to_dict() + ) + return t.OrgSAMLConnection.from_dict(res or {}) + + def update_org_saml_connection( + self, req: t.UpdateOrgSAMLConnectionRequest + ) -> t.OrgSAMLConnection: + res = self._invoke( + "update_org_saml_connection", d.ADMIN["update_org_saml_connection"], req.to_dict() + ) + return t.OrgSAMLConnection.from_dict(res or {}) + + def delete_org_saml_connection(self, req: t.OrgSAMLConnectionRequest) -> t.GenericResponse: + """Destructive: org members lose this SAML SSO path.""" + res = self._invoke( + "delete_org_saml_connection", d.ADMIN["delete_org_saml_connection"], req.to_dict() + ) + return t.GenericResponse.from_dict(res or {}) + + def get_org_saml_connection(self, req: t.OrgSAMLConnectionRequest) -> t.OrgSAMLConnection: + res = self._invoke( + "get_org_saml_connection", d.ADMIN["get_org_saml_connection"], req.to_dict() + ) + return t.OrgSAMLConnection.from_dict(res or {}) + + # -- SCIM endpoints ------------------------------------------------------ # + def create_scim_endpoint( + self, req: t.CreateScimEndpointRequest + ) -> t.CreateScimEndpointResponse: + """The returned bearer token is shown ONCE and can never be retrieved again.""" + res = self._invoke("create_scim_endpoint", d.ADMIN["create_scim_endpoint"], req.to_dict()) + return t.CreateScimEndpointResponse.from_dict(res or {}) + + def rotate_scim_token(self, req: t.ScimEndpointRequest) -> t.CreateScimEndpointResponse: + """Destructive: invalidates the old token; the new one is shown ONCE.""" + res = self._invoke("rotate_scim_token", d.ADMIN["rotate_scim_token"], req.to_dict()) + return t.CreateScimEndpointResponse.from_dict(res or {}) + + def delete_scim_endpoint(self, req: t.ScimEndpointRequest) -> t.GenericResponse: + """Destructive: the org's SCIM provisioning stops working.""" + res = self._invoke("delete_scim_endpoint", d.ADMIN["delete_scim_endpoint"], req.to_dict()) + return t.GenericResponse.from_dict(res or {}) + + def get_scim_endpoint(self, req: t.ScimEndpointRequest) -> t.ScimEndpoint: + res = self._invoke("get_scim_endpoint", d.ADMIN["get_scim_endpoint"], req.to_dict()) + return t.ScimEndpoint.from_dict(res or {}) diff --git a/src/authorizer/async_admin_client.py b/src/authorizer/async_admin_client.py index 64e8d72..e0fb008 100644 --- a/src/authorizer/async_admin_client.py +++ b/src/authorizer/async_admin_client.py @@ -278,3 +278,204 @@ async def fga_reset(self) -> t.GenericResponse: """Destructive: deletes the entire fine-grained authorization store.""" res = await self._invoke("fga_reset", d.ADMIN["fga_reset"], None) return t.GenericResponse.from_dict(res or {}) + + # -- clients (service accounts / machine identities) ------------------- # + async def create_client(self, req: t.CreateClientRequest) -> t.CreateClientResponse: + """The returned client_secret is shown ONCE and can never be retrieved again.""" + res = await self._invoke("create_client", d.ADMIN["create_client"], req.to_dict()) + return t.CreateClientResponse.from_dict(res or {}) + + async def update_client(self, req: t.UpdateClientRequest) -> t.Client: + res = await self._invoke("update_client", d.ADMIN["update_client"], req.to_dict()) + return t.Client.from_dict(res or {}) + + async def delete_client(self, req: t.ClientRequest) -> t.GenericResponse: + """Destructive: permanently deletes the client; its tokens stop resolving.""" + res = await self._invoke("delete_client", d.ADMIN["delete_client"], req.to_dict()) + return t.GenericResponse.from_dict(res or {}) + + async def rotate_client_secret(self, req: t.ClientRequest) -> t.CreateClientResponse: + """Destructive: invalidates the old secret; the new one is shown ONCE.""" + res = await self._invoke( + "rotate_client_secret", d.ADMIN["rotate_client_secret"], req.to_dict() + ) + return t.CreateClientResponse.from_dict(res or {}) + + async def get_client(self, req: t.ClientRequest) -> t.Client: + res = await self._invoke("get_client", d.ADMIN["get_client"], req.to_dict()) + return t.Client.from_dict(res or {}) + + async def clients(self, req: t.ListClientsRequest | None = None) -> t.ClientsResponse: + res = await self._invoke("clients", d.ADMIN["clients"], req.to_dict() if req else None) + return t.ClientsResponse.from_dict(res or {}) + + # -- trusted issuers ---------------------------------------------------- # + async def add_trusted_issuer(self, req: t.AddTrustedIssuerRequest) -> t.TrustedIssuer: + res = await self._invoke( + "add_trusted_issuer", d.ADMIN["add_trusted_issuer"], req.to_dict() + ) + return t.TrustedIssuer.from_dict(res or {}) + + async def update_trusted_issuer(self, req: t.UpdateTrustedIssuerRequest) -> t.TrustedIssuer: + res = await self._invoke( + "update_trusted_issuer", d.ADMIN["update_trusted_issuer"], req.to_dict() + ) + return t.TrustedIssuer.from_dict(res or {}) + + async def delete_trusted_issuer(self, req: t.TrustedIssuerRequest) -> t.GenericResponse: + """Destructive: tokens from this issuer stop authenticating.""" + res = await self._invoke( + "delete_trusted_issuer", d.ADMIN["delete_trusted_issuer"], req.to_dict() + ) + return t.GenericResponse.from_dict(res or {}) + + async def get_trusted_issuer(self, req: t.TrustedIssuerRequest) -> t.TrustedIssuer: + res = await self._invoke( + "get_trusted_issuer", d.ADMIN["get_trusted_issuer"], req.to_dict() + ) + return t.TrustedIssuer.from_dict(res or {}) + + async def trusted_issuers( + self, req: t.ListTrustedIssuersRequest | None = None + ) -> t.TrustedIssuersResponse: + res = await self._invoke( + "trusted_issuers", d.ADMIN["trusted_issuers"], req.to_dict() if req else None + ) + return t.TrustedIssuersResponse.from_dict(res or {}) + + # -- organizations ------------------------------------------------------ # + async def create_organization(self, req: t.CreateOrganizationRequest) -> t.Organization: + res = await self._invoke( + "create_organization", d.ADMIN["create_organization"], req.to_dict() + ) + return t.Organization.from_dict(res or {}) + + async def update_organization(self, req: t.UpdateOrganizationRequest) -> t.Organization: + res = await self._invoke( + "update_organization", d.ADMIN["update_organization"], req.to_dict() + ) + return t.Organization.from_dict(res or {}) + + async def delete_organization(self, req: t.OrganizationRequest) -> t.GenericResponse: + """Destructive: permanently deletes the organization.""" + res = await self._invoke( + "delete_organization", d.ADMIN["delete_organization"], req.to_dict() + ) + return t.GenericResponse.from_dict(res or {}) + + async def add_org_member(self, req: t.AddOrgMemberRequest) -> t.OrgMember: + res = await self._invoke("add_org_member", d.ADMIN["add_org_member"], req.to_dict()) + return t.OrgMember.from_dict(res or {}) + + async def remove_org_member(self, req: t.RemoveOrgMemberRequest) -> t.GenericResponse: + res = await self._invoke("remove_org_member", d.ADMIN["remove_org_member"], req.to_dict()) + return t.GenericResponse.from_dict(res or {}) + + async def get_organization(self, req: t.OrganizationRequest) -> t.Organization: + res = await self._invoke("get_organization", d.ADMIN["get_organization"], req.to_dict()) + return t.Organization.from_dict(res or {}) + + async def organizations( + self, req: t.ListOrganizationsRequest | None = None + ) -> t.OrganizationsResponse: + res = await self._invoke( + "organizations", d.ADMIN["organizations"], req.to_dict() if req else None + ) + return t.OrganizationsResponse.from_dict(res or {}) + + async def org_members(self, req: t.ListOrgMembersRequest) -> t.OrgMembersResponse: + res = await self._invoke("org_members", d.ADMIN["org_members"], req.to_dict()) + return t.OrgMembersResponse.from_dict(res or {}) + + # -- org SSO connections ------------------------------------------------ # + async def create_org_oidc_connection( + self, req: t.CreateOrgOIDCConnectionRequest + ) -> t.OrgOIDCConnection: + res = await self._invoke( + "create_org_oidc_connection", d.ADMIN["create_org_oidc_connection"], req.to_dict() + ) + return t.OrgOIDCConnection.from_dict(res or {}) + + async def update_org_oidc_connection( + self, req: t.UpdateOrgOIDCConnectionRequest + ) -> t.OrgOIDCConnection: + res = await self._invoke( + "update_org_oidc_connection", d.ADMIN["update_org_oidc_connection"], req.to_dict() + ) + return t.OrgOIDCConnection.from_dict(res or {}) + + async def delete_org_oidc_connection( + self, req: t.OrgOIDCConnectionRequest + ) -> t.GenericResponse: + """Destructive: org members lose this OIDC SSO path.""" + res = await self._invoke( + "delete_org_oidc_connection", d.ADMIN["delete_org_oidc_connection"], req.to_dict() + ) + return t.GenericResponse.from_dict(res or {}) + + async def get_org_oidc_connection( + self, req: t.OrgOIDCConnectionRequest + ) -> t.OrgOIDCConnection: + res = await self._invoke( + "get_org_oidc_connection", d.ADMIN["get_org_oidc_connection"], req.to_dict() + ) + return t.OrgOIDCConnection.from_dict(res or {}) + + async def create_org_saml_connection( + self, req: t.CreateOrgSAMLConnectionRequest + ) -> t.OrgSAMLConnection: + res = await self._invoke( + "create_org_saml_connection", d.ADMIN["create_org_saml_connection"], req.to_dict() + ) + return t.OrgSAMLConnection.from_dict(res or {}) + + async def update_org_saml_connection( + self, req: t.UpdateOrgSAMLConnectionRequest + ) -> t.OrgSAMLConnection: + res = await self._invoke( + "update_org_saml_connection", d.ADMIN["update_org_saml_connection"], req.to_dict() + ) + return t.OrgSAMLConnection.from_dict(res or {}) + + async def delete_org_saml_connection( + self, req: t.OrgSAMLConnectionRequest + ) -> t.GenericResponse: + """Destructive: org members lose this SAML SSO path.""" + res = await self._invoke( + "delete_org_saml_connection", d.ADMIN["delete_org_saml_connection"], req.to_dict() + ) + return t.GenericResponse.from_dict(res or {}) + + async def get_org_saml_connection( + self, req: t.OrgSAMLConnectionRequest + ) -> t.OrgSAMLConnection: + res = await self._invoke( + "get_org_saml_connection", d.ADMIN["get_org_saml_connection"], req.to_dict() + ) + return t.OrgSAMLConnection.from_dict(res or {}) + + # -- SCIM endpoints ------------------------------------------------------ # + async def create_scim_endpoint( + self, req: t.CreateScimEndpointRequest + ) -> t.CreateScimEndpointResponse: + """The returned bearer token is shown ONCE and can never be retrieved again.""" + res = await self._invoke( + "create_scim_endpoint", d.ADMIN["create_scim_endpoint"], req.to_dict() + ) + return t.CreateScimEndpointResponse.from_dict(res or {}) + + async def rotate_scim_token(self, req: t.ScimEndpointRequest) -> t.CreateScimEndpointResponse: + """Destructive: invalidates the old token; the new one is shown ONCE.""" + res = await self._invoke("rotate_scim_token", d.ADMIN["rotate_scim_token"], req.to_dict()) + return t.CreateScimEndpointResponse.from_dict(res or {}) + + async def delete_scim_endpoint(self, req: t.ScimEndpointRequest) -> t.GenericResponse: + """Destructive: the org's SCIM provisioning stops working.""" + res = await self._invoke( + "delete_scim_endpoint", d.ADMIN["delete_scim_endpoint"], req.to_dict() + ) + return t.GenericResponse.from_dict(res or {}) + + async def get_scim_endpoint(self, req: t.ScimEndpointRequest) -> t.ScimEndpoint: + res = await self._invoke("get_scim_endpoint", d.ADMIN["get_scim_endpoint"], req.to_dict()) + return t.ScimEndpoint.from_dict(res or {}) diff --git a/src/authorizer/async_client.py b/src/authorizer/async_client.py index 94c60db..218af75 100644 --- a/src/authorizer/async_client.py +++ b/src/authorizer/async_client.py @@ -16,6 +16,7 @@ build_graphql_request, build_headers, build_oauth_request, + build_token_body, parse_graphql_data, parse_graphql_response, parse_oauth_response, @@ -92,6 +93,19 @@ async def _oauth(self, path: str, body: dict[str, Any]) -> dict[str, Any]: res = await self._send(spec) return parse_oauth_response(res.status_code, res.content) + async def _oauth_form(self, path: str, body: dict[str, str]) -> dict[str, Any]: + """POST an application/x-www-form-urlencoded OAuth request (RFC 6749 §4.1.3).""" + headers = build_headers( + self._config, {"Content-Type": "application/x-www-form-urlencoded"} + ) + try: + res = await self._http.post( + f"{self._config.authorizer_url}{path}", data=body, headers=headers + ) + except httpx.HTTPError as e: + raise AuthorizerConnectionError(str(e)) from e + return parse_oauth_response(res.status_code, res.content) + async def _invoke( self, method: str, @@ -227,17 +241,16 @@ async def list_permissions( # -- OAuth REST ------------------------------------------------------- # async def get_token(self, req: t.GetTokenRequest) -> t.GetTokenResponse: - grant_type = req.grant_type or "authorization_code" - if grant_type == "refresh_token" and not (req.refresh_token and req.refresh_token.strip()): - raise ValueError("refresh_token is required for refresh_token grant") - body: dict[str, Any] = { - "client_id": self._config.client_id, - "code": req.code or "", - "code_verifier": req.code_verifier or "", - "grant_type": grant_type, - "refresh_token": req.refresh_token or "", - } - return t.GetTokenResponse.from_dict(await self._oauth("/oauth/token", body)) + """Exchange credentials for tokens at ``/oauth/token``. + + Supported grants: ``authorization_code`` (default), ``refresh_token``, + ``client_credentials`` (RFC 6749 §4.4) and RFC 8693 token exchange + (:data:`types.GRANT_TYPE_TOKEN_EXCHANGE`). The machine grants are for + trusted server-side code only — never expose ``client_secret``, + ``client_assertion``, or subject/actor tokens to untrusted code. + """ + body = build_token_body(self._config.client_id, req) + return t.GetTokenResponse.from_dict(await self._oauth_form("/oauth/token", body)) async def revoke_token(self, req: t.RevokeTokenRequest) -> t.GenericResponse: if not req.refresh_token or not req.refresh_token.strip(): diff --git a/src/authorizer/client.py b/src/authorizer/client.py index aeb7a4a..432c58a 100644 --- a/src/authorizer/client.py +++ b/src/authorizer/client.py @@ -16,6 +16,7 @@ build_graphql_request, build_headers, build_oauth_request, + build_token_body, parse_graphql_data, parse_graphql_response, parse_oauth_response, @@ -92,6 +93,19 @@ def _oauth(self, path: str, body: dict[str, Any]) -> dict[str, Any]: res = self._send(spec) return parse_oauth_response(res.status_code, res.content) + def _oauth_form(self, path: str, body: dict[str, str]) -> dict[str, Any]: + """POST an application/x-www-form-urlencoded OAuth request (RFC 6749 §4.1.3).""" + headers = build_headers( + self._config, {"Content-Type": "application/x-www-form-urlencoded"} + ) + try: + res = self._http.post( + f"{self._config.authorizer_url}{path}", data=body, headers=headers + ) + except httpx.HTTPError as e: + raise AuthorizerConnectionError(str(e)) from e + return parse_oauth_response(res.status_code, res.content) + def _invoke( self, method: str, @@ -217,17 +231,16 @@ def list_permissions( # -- OAuth REST ------------------------------------------------------- # def get_token(self, req: t.GetTokenRequest) -> t.GetTokenResponse: - grant_type = req.grant_type or "authorization_code" - if grant_type == "refresh_token" and not (req.refresh_token and req.refresh_token.strip()): - raise ValueError("refresh_token is required for refresh_token grant") - body: dict[str, Any] = { - "client_id": self._config.client_id, - "code": req.code or "", - "code_verifier": req.code_verifier or "", - "grant_type": grant_type, - "refresh_token": req.refresh_token or "", - } - return t.GetTokenResponse.from_dict(self._oauth("/oauth/token", body)) + """Exchange credentials for tokens at ``/oauth/token``. + + Supported grants: ``authorization_code`` (default), ``refresh_token``, + ``client_credentials`` (RFC 6749 §4.4) and RFC 8693 token exchange + (:data:`types.GRANT_TYPE_TOKEN_EXCHANGE`). The machine grants are for + trusted server-side code only — never expose ``client_secret``, + ``client_assertion``, or subject/actor tokens to untrusted code. + """ + body = build_token_body(self._config.client_id, req) + return t.GetTokenResponse.from_dict(self._oauth_form("/oauth/token", body)) def revoke_token(self, req: t.RevokeTokenRequest) -> t.GenericResponse: if not req.refresh_token or not req.refresh_token.strip(): diff --git a/src/authorizer/types.py b/src/authorizer/types.py index 111e77f..834f0fb 100644 --- a/src/authorizer/types.py +++ b/src/authorizer/types.py @@ -7,6 +7,23 @@ from enum import Enum from typing import Any +# --------------------------------------------------------------------------- # +# OAuth2 grant-type identifiers accepted by /oauth/token. +# client_credentials (RFC 6749 §4.4) and token-exchange (RFC 8693) are +# machine/service flows — server-side only. +# --------------------------------------------------------------------------- # +GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" +GRANT_TYPE_REFRESH_TOKEN = "refresh_token" +GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" +GRANT_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange" + +# RFC 8693 token-type URNs for subject_token_type / actor_token_type. +TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" +TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt" + +# RFC 7523 JWT-bearer client_assertion_type (secretless client auth). +CLIENT_ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + # --------------------------------------------------------------------------- # # Enums @@ -204,6 +221,29 @@ class GetTokenRequest(_Request): grant_type: str | None = None refresh_token: str | None = None code_verifier: str | None = None + # -- client_credentials (RFC 6749 §4.4) — SERVER-SIDE ONLY -------------- # + # client_secret authenticates the service account. Never expose it to + # untrusted code. + client_secret: str | None = None + # scope is the space-delimited OAuth2 scope parameter (RFC 6749 §3.3); + # omitted = the service account's full allowed scope set. + scope: str | None = None + # client_assertion / client_assertion_type carry the RFC 7523 JWT-bearer + # client credential — the secretless workload-identity path (K8s SA + # tokens, SPIFFE JWT-SVIDs, cloud OIDC tokens). + client_assertion: str | None = None + client_assertion_type: str | None = None + # -- RFC 8693 token exchange (delegation) — SERVER-SIDE ONLY ------------ # + # subject_token carries the authority being exercised (the user's token); + # actor_token carries the acting agent's token. See the TOKEN_TYPE_* + # constants for the *_token_type URNs. + subject_token: str | None = None + subject_token_type: str | None = None + actor_token: str | None = None + actor_token_type: str | None = None + # resource is the RFC 8707 resource indicator the issued token is + # audience-bound to (exactly one is required for token exchange). + resource: str | None = None @dataclass @@ -371,8 +411,15 @@ def from_dict(cls, data: dict[str, Any]) -> MetaData: class GetTokenResponse: access_token: str = "" expires_in: int = 0 + # id_token is only issued on user grants (authorization_code / + # refresh_token) — absent for client_credentials and token exchange. id_token: str = "" refresh_token: str | None = None + token_type: str | None = None + # scope / issued_token_type are returned by the client_credentials and + # token-exchange grants (RFC 6749 §5.1 / RFC 8693 §2.2). + scope: str | None = None + issued_token_type: str | None = None @classmethod def from_dict(cls, data: dict[str, Any]) -> GetTokenResponse: @@ -604,6 +651,209 @@ class FgaExpandRequest(_Request): object: str +# -- clients (service accounts / machine identities) ------------------------ # +@dataclass +class CreateClientRequest(_Request): + name: str + # allowed_scopes must contain at least one non-empty scope. + allowed_scopes: list[str] + description: str | None = None + + +@dataclass +class UpdateClientRequest(_Request): + id: str + name: str | None = None + description: str | None = None + allowed_scopes: list[str] | None = None + is_active: bool | None = None + + +@dataclass +class ClientRequest(_Request): + id: str + + +@dataclass +class ListClientsRequest(_Request): + pagination: PaginatedRequest | None = None + + +# -- trusted issuers --------------------------------------------------------- # +@dataclass +class AddTrustedIssuerRequest(_Request): + service_account_id: str + name: str + issuer_url: str + # key_source_type: "oidc_discovery" | "static_jwks_url" | "spiffe_bundle_endpoint" + key_source_type: str + expected_aud: str + # issuer_type: "kubernetes_sa" | "spiffe_jwt" | "oidc" | "cloud_oidc" + issuer_type: str + jwks_url: str | None = None + # subject_claim defaults to "sub" if omitted. + subject_claim: str | None = None + # allowed_subjects: comma-separated exact subject allow-list. Empty = deny-all. + allowed_subjects: str | None = None + spiffe_refresh_hint_seconds: int | None = None + + +@dataclass +class UpdateTrustedIssuerRequest(_Request): + id: str + name: str | None = None + jwks_url: str | None = None + expected_aud: str | None = None + allowed_subjects: str | None = None + is_active: bool | None = None + spiffe_refresh_hint_seconds: int | None = None + + +@dataclass +class TrustedIssuerRequest(_Request): + id: str + + +@dataclass +class ListTrustedIssuersRequest(_Request): + service_account_id: str | None = None + pagination: PaginatedRequest | None = None + + +# -- organizations ------------------------------------------------------------ # +@dataclass +class CreateOrganizationRequest(_Request): + # name must be a unique, URL-safe slug. + name: str + display_name: str | None = None + + +@dataclass +class UpdateOrganizationRequest(_Request): + id: str + name: str | None = None + display_name: str | None = None + enabled: bool | None = None + + +@dataclass +class OrganizationRequest(_Request): + id: str + + +@dataclass +class ListOrganizationsRequest(_Request): + pagination: PaginatedRequest | None = None + + +@dataclass +class AddOrgMemberRequest(_Request): + org_id: str + user_id: str + # roles defaults to an empty set when omitted. + roles: list[str] | None = None + + +@dataclass +class RemoveOrgMemberRequest(_Request): + org_id: str + user_id: str + + +@dataclass +class ListOrgMembersRequest(_Request): + org_id: str + pagination: PaginatedRequest | None = None + + +# -- org SSO connections ------------------------------------------------------ # +@dataclass +class CreateOrgOIDCConnectionRequest(_Request): + org_id: str + name: str + # issuer_url: the upstream IdP issuer (its OIDC discovery base). + issuer_url: str + # client_id / client_secret: the credentials Authorizer holds AT the + # upstream IdP. The secret is stored encrypted and never returned. + client_id: str + client_secret: str + # scopes: space-separated. Defaults to "openid profile email" when omitted. + scopes: str | None = None + redirect_uri: str | None = None + + +@dataclass +class UpdateOrgOIDCConnectionRequest(_Request): + id: str + name: str | None = None + issuer_url: str | None = None + client_id: str | None = None + # Supplying client_secret rotates it; omitting leaves the stored secret intact. + client_secret: str | None = None + scopes: str | None = None + redirect_uri: str | None = None + is_active: bool | None = None + + +@dataclass +class OrgOIDCConnectionRequest(_Request): + # Look up by connection id OR by org_id (supply exactly one). + id: str | None = None + org_id: str | None = None + + +@dataclass +class CreateOrgSAMLConnectionRequest(_Request): + org_id: str + name: str + # idp_entity_id: the upstream IdP entity ID (the assertion Issuer). + idp_entity_id: str + # idp_sso_url: the IdP Single Sign-On endpoint (HTTP-Redirect binding). + idp_sso_url: str + # idp_certificate: the IdP X.509 signing certificate (PEM). + idp_certificate: str + # sp_entity_id / acs_url: override the host-derived SP identity. + sp_entity_id: str | None = None + acs_url: str | None = None + # attribute_mapping: JSON, e.g. {"email":"email","given_name":"firstName"}. + attribute_mapping: str | None = None + # allow_idp_initiated: default false (SP-initiated only). + allow_idp_initiated: bool | None = None + + +@dataclass +class UpdateOrgSAMLConnectionRequest(_Request): + id: str + name: str | None = None + idp_entity_id: str | None = None + idp_sso_url: str | None = None + # Supplying idp_certificate replaces it; omitting leaves the stored cert intact. + idp_certificate: str | None = None + sp_entity_id: str | None = None + acs_url: str | None = None + attribute_mapping: str | None = None + allow_idp_initiated: bool | None = None + is_active: bool | None = None + + +@dataclass +class OrgSAMLConnectionRequest(_Request): + # Look up by connection id OR by org_id (supply exactly one). + id: str | None = None + org_id: str | None = None + + +# -- SCIM endpoints (one per org, keyed by org_id) ---------------------------- # +@dataclass +class CreateScimEndpointRequest(_Request): + org_id: str + + +@dataclass +class ScimEndpointRequest(_Request): + org_id: str + + # --------------------------------------------------------------------------- # # Admin response types # --------------------------------------------------------------------------- # @@ -897,3 +1147,224 @@ class FgaExpandResponse: @classmethod def from_dict(cls, data: dict[str, Any]) -> FgaExpandResponse: return cls(tree=str(data.get("tree", ""))) + + +# Client is a registered OAuth client / service account. client_secret is +# NEVER part of this shape — it is returned exactly once in +# CreateClientResponse (creation and rotation) and never again. +@dataclass +class Client: + id: str = "" + name: str = "" + description: str | None = None + allowed_scopes: list[str] = field(default_factory=list) + is_active: bool = False + created_at: int | None = None + updated_at: int | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Client: + return cls(**_known(cls, data)) + + +@dataclass +class CreateClientResponse: + client: Client = field(default_factory=Client) + # client_secret is returned ONCE at creation and ONCE at rotation. Store + # it securely; it can never be retrieved again. + client_secret: str = "" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CreateClientResponse: + raw = data.get("client") + return cls( + client=Client.from_dict(raw) if isinstance(raw, dict) else Client(), + client_secret=str(data.get("client_secret", "")), + ) + + +@dataclass +class ClientsResponse: + clients: list[Client] = field(default_factory=list) + pagination: Pagination = field(default_factory=Pagination) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ClientsResponse: + raw = data.get("clients") + items = raw if isinstance(raw, list) else [] + return cls( + clients=[Client.from_dict(c) for c in items if isinstance(c, dict)], + pagination=_pagination(data), + ) + + +@dataclass +class TrustedIssuer: + id: str = "" + service_account_id: str = "" + name: str = "" + issuer_url: str = "" + key_source_type: str = "" + jwks_url: str | None = None + expected_aud: str = "" + subject_claim: str = "" + # allowed_subjects: comma-separated exact subject allow-list. Empty = deny-all. + allowed_subjects: str | None = None + issuer_type: str = "" + is_active: bool = False + spiffe_refresh_hint_seconds: int | None = None + created_at: int | None = None + updated_at: int | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TrustedIssuer: + return cls(**_known(cls, data)) + + +@dataclass +class TrustedIssuersResponse: + trusted_issuers: list[TrustedIssuer] = field(default_factory=list) + pagination: Pagination = field(default_factory=Pagination) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TrustedIssuersResponse: + raw = data.get("trusted_issuers") + items = raw if isinstance(raw, list) else [] + return cls( + trusted_issuers=[TrustedIssuer.from_dict(x) for x in items if isinstance(x, dict)], + pagination=_pagination(data), + ) + + +@dataclass +class Organization: + id: str = "" + # name is a unique, URL-safe slug identifying the organization. + name: str = "" + display_name: str | None = None + enabled: bool = False + created_at: int | None = None + updated_at: int | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Organization: + return cls(**_known(cls, data)) + + +@dataclass +class OrganizationsResponse: + organizations: list[Organization] = field(default_factory=list) + pagination: Pagination = field(default_factory=Pagination) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> OrganizationsResponse: + raw = data.get("organizations") + items = raw if isinstance(raw, list) else [] + return cls( + organizations=[Organization.from_dict(o) for o in items if isinstance(o, dict)], + pagination=_pagination(data), + ) + + +@dataclass +class OrgMember: + id: str = "" + org_id: str = "" + user_id: str = "" + # roles is the set of per-organization roles granted to this member. + roles: list[str] = field(default_factory=list) + created_at: int | None = None + updated_at: int | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> OrgMember: + return cls(**_known(cls, data)) + + +@dataclass +class OrgMembersResponse: + org_members: list[OrgMember] = field(default_factory=list) + pagination: Pagination = field(default_factory=Pagination) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> OrgMembersResponse: + raw = data.get("org_members") + items = raw if isinstance(raw, list) else [] + return cls( + org_members=[OrgMember.from_dict(m) for m in items if isinstance(m, dict)], + pagination=_pagination(data), + ) + + +# OrgOIDCConnection: per-org upstream OIDC IdP brokered by Authorizer as a +# Relying Party. The upstream client_secret is NEVER projected here. +@dataclass +class OrgOIDCConnection: + id: str = "" + org_id: str = "" + name: str = "" + issuer_url: str = "" + # sso_client_id: the client_id Authorizer uses AT the upstream IdP. + sso_client_id: str = "" + scopes: str | None = None + redirect_uri: str | None = None + is_active: bool = False + created_at: int | None = None + updated_at: int | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> OrgOIDCConnection: + return cls(**_known(cls, data)) + + +# OrgSAMLConnection: per-org upstream SAML 2.0 IdP for which Authorizer acts +# as the Service Provider. The IdP signing certificate is never projected back. +@dataclass +class OrgSAMLConnection: + id: str = "" + org_id: str = "" + name: str = "" + idp_entity_id: str = "" + idp_sso_url: str | None = None + sp_entity_id: str | None = None + acs_url: str | None = None + attribute_mapping: str | None = None + allow_idp_initiated: bool = False + is_active: bool = False + created_at: int | None = None + updated_at: int | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> OrgSAMLConnection: + return cls(**_known(cls, data)) + + +# ScimEndpoint: per-org inbound SCIM 2.0 connection. The bearer token is NEVER +# returned here; it is returned exactly once in CreateScimEndpointResponse. +@dataclass +class ScimEndpoint: + id: str = "" + org_id: str = "" + enabled: bool = False + created_at: int | None = None + updated_at: int | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ScimEndpoint: + return cls(**_known(cls, data)) + + +@dataclass +class CreateScimEndpointResponse: + scim_endpoint: ScimEndpoint = field(default_factory=ScimEndpoint) + # token is the bearer credential the org's IdP presents at /scim/v2/. It + # is returned ONCE at creation and ONCE at rotation. Store it securely. + token: str = "" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CreateScimEndpointResponse: + raw = data.get("scim_endpoint") + return cls( + scim_endpoint=ScimEndpoint.from_dict(raw) if isinstance(raw, dict) else ScimEndpoint(), + token=str(data.get("token", "")), + ) diff --git a/tests/test_admin_client.py b/tests/test_admin_client.py index c48a564..aec1000 100644 --- a/tests/test_admin_client.py +++ b/tests/test_admin_client.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json + import pytest import respx from httpx import Response @@ -183,3 +185,213 @@ def test_fga_reset_rest() -> None: ) with _admin("rest") as c: assert c.fga_reset().message == "reset" + + +# -- machine-agent-identity ops (graphql-only) -------------------------------- # +@respx.mock +def test_create_client_returns_secret_once() -> None: + route = respx.post(f"{URL}/graphql").mock( + return_value=Response( + 200, + json={ + "data": { + "_create_client": { + "client": { + "id": "c1", + "name": "agent", + "allowed_scopes": ["read:users"], + "is_active": True, + }, + "client_secret": "shown-once", + } + } + }, + ) + ) + with _admin() as c: + out = c.create_client( + t.CreateClientRequest(name="agent", allowed_scopes=["read:users"]) + ) + assert out.client.id == "c1" + assert out.client.allowed_scopes == ["read:users"] + assert out.client_secret == "shown-once" + body = json.loads(route.calls[0].request.content) + assert "_create_client" in body["query"] + assert body["variables"]["data"] == {"name": "agent", "allowed_scopes": ["read:users"]} + + +@respx.mock +def test_clients_parses_pagination_and_list() -> None: + respx.post(f"{URL}/graphql").mock( + return_value=Response( + 200, + json={ + "data": { + "_clients": { + "pagination": {"total": 2, "page": 1, "limit": 10, "offset": 0}, + "clients": [{"id": "c1", "name": "a"}, {"id": "c2", "name": "b"}], + } + } + }, + ) + ) + with _admin() as c: + out = c.clients() + assert out.pagination.total == 2 + assert [x.id for x in out.clients] == ["c1", "c2"] + + +@respx.mock +def test_add_trusted_issuer() -> None: + route = respx.post(f"{URL}/graphql").mock( + return_value=Response( + 200, + json={ + "data": { + "_add_trusted_issuer": { + "id": "ti1", + "service_account_id": "c1", + "name": "k8s", + "issuer_url": "https://k8s.local", + "key_source_type": "oidc_discovery", + "expected_aud": "authorizer", + "subject_claim": "sub", + "issuer_type": "kubernetes_sa", + "is_active": True, + } + } + }, + ) + ) + with _admin() as c: + out = c.add_trusted_issuer( + t.AddTrustedIssuerRequest( + service_account_id="c1", + name="k8s", + issuer_url="https://k8s.local", + key_source_type="oidc_discovery", + expected_aud="authorizer", + issuer_type="kubernetes_sa", + allowed_subjects="system:serviceaccount:ns:sa", + ) + ) + assert out.id == "ti1" + assert out.issuer_type == "kubernetes_sa" + sent = json.loads(route.calls[0].request.content)["variables"]["data"] + assert sent["allowed_subjects"] == "system:serviceaccount:ns:sa" + assert "jwks_url" not in sent # unset optionals are dropped + + +@respx.mock +def test_create_organization_and_add_member() -> None: + respx.post(f"{URL}/graphql").mock( + side_effect=[ + Response( + 200, + json={"data": {"_create_organization": {"id": "o1", "name": "acme"}}}, + ), + Response( + 200, + json={ + "data": { + "_add_org_member": { + "id": "m1", + "org_id": "o1", + "user_id": "u1", + "roles": ["admin"], + } + } + }, + ), + ] + ) + with _admin() as c: + org = c.create_organization(t.CreateOrganizationRequest(name="acme")) + member = c.add_org_member( + t.AddOrgMemberRequest(org_id=org.id, user_id="u1", roles=["admin"]) + ) + assert org.name == "acme" + assert member.roles == ["admin"] + + +@respx.mock +def test_rotate_scim_token_returns_token_once() -> None: + respx.post(f"{URL}/graphql").mock( + return_value=Response( + 200, + json={ + "data": { + "_rotate_scim_token": { + "scim_endpoint": {"id": "s1", "org_id": "o1", "enabled": True}, + "token": "bearer-once", + } + } + }, + ) + ) + with _admin() as c: + out = c.rotate_scim_token(t.ScimEndpointRequest(org_id="o1")) + assert out.scim_endpoint.id == "s1" + assert out.token == "bearer-once" + + +def test_mai_methods_not_available_over_rest() -> None: + with _admin("rest") as c: + with pytest.raises(AuthorizerError) as ei: + c.create_client(t.CreateClientRequest(name="a", allowed_scopes=["s"])) + assert "not available over rest" in str(ei.value) + with _admin("rest") as c: + with pytest.raises(AuthorizerError): + c.trusted_issuers() + + +@pytest.mark.asyncio +@respx.mock +async def test_async_create_client_and_org_oidc_connection() -> None: + from authorizer.async_admin_client import AsyncAuthorizerAdminClient + + respx.post(f"{URL}/graphql").mock( + side_effect=[ + Response( + 200, + json={ + "data": { + "_create_client": { + "client": {"id": "c1", "name": "agent"}, + "client_secret": "sec", + } + } + }, + ), + Response( + 200, + json={ + "data": { + "_create_org_oidc_connection": { + "id": "oc1", + "org_id": "o1", + "name": "okta", + "issuer_url": "https://okta.example.com", + "sso_client_id": "up-cid", + "is_active": True, + } + } + }, + ), + ] + ) + async with AsyncAuthorizerAdminClient(URL, "admin") as c: + created = await c.create_client( + t.CreateClientRequest(name="agent", allowed_scopes=["read:users"]) + ) + conn = await c.create_org_oidc_connection( + t.CreateOrgOIDCConnectionRequest( + org_id="o1", + name="okta", + issuer_url="https://okta.example.com", + client_id="up-cid", + client_secret="up-sec", + ) + ) + assert created.client_secret == "sec" + assert conn.sso_client_id == "up-cid" diff --git a/tests/test_admin_parity.py b/tests/test_admin_parity.py index 10a3d67..ae8571e 100644 --- a/tests/test_admin_parity.py +++ b/tests/test_admin_parity.py @@ -1,4 +1,4 @@ -"""Admin sync/async parity + spec coverage of all 35 admin methods.""" +"""Admin sync/async parity + spec coverage of all 66 admin methods.""" from __future__ import annotations @@ -43,6 +43,39 @@ "admin_signup", "update_env", "generate_jwt_keys", + # Machine-agent-identity ops (graphql-only until the vendored stubs are + # re-vendored; orgs/SSO/SCIM are graphql-only on the server too). + "create_client", + "update_client", + "delete_client", + "rotate_client_secret", + "get_client", + "clients", + "add_trusted_issuer", + "update_trusted_issuer", + "delete_trusted_issuer", + "get_trusted_issuer", + "trusted_issuers", + "create_organization", + "update_organization", + "delete_organization", + "add_org_member", + "remove_org_member", + "get_organization", + "organizations", + "org_members", + "create_org_oidc_connection", + "update_org_oidc_connection", + "delete_org_oidc_connection", + "get_org_oidc_connection", + "create_org_saml_connection", + "update_org_saml_connection", + "delete_org_saml_connection", + "get_org_saml_connection", + "create_scim_endpoint", + "rotate_scim_token", + "delete_scim_endpoint", + "get_scim_endpoint", ] @@ -54,7 +87,7 @@ def test_both_admin_clients_expose_same_surface() -> None: def test_dispatch_table_covers_every_admin_method() -> None: assert set(d.ADMIN) == set(ADMIN_METHODS) - assert len(d.ADMIN) == 35 + assert len(d.ADMIN) == 66 def test_protocol_availability_matches_spec() -> None: @@ -66,3 +99,6 @@ def test_protocol_availability_matches_spec() -> None: assert d.ADMIN[name].protocols == ("rest", "grpc") # full coverage assert d.ADMIN["users"].protocols == ("graphql", "rest", "grpc") + # machine-agent-identity ops are graphql-only for now + for name in ("create_client", "trusted_issuers", "create_organization", "get_scim_endpoint"): + assert d.ADMIN[name].protocols == ("graphql",) diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 8da3943..7134a92 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -2,7 +2,7 @@ from __future__ import annotations -import json +from urllib.parse import parse_qs import pytest import respx @@ -60,7 +60,51 @@ async def test_async_get_token() -> None: async with AsyncAuthorizerClient("cid", "https://auth.example.com") as c: out = await c.get_token(t.GetTokenRequest(code="abc", code_verifier="ver")) assert out.access_token == "tok" - assert json.loads(route.calls[0].request.content)["client_id"] == "cid" + request = route.calls[0].request + assert request.headers["content-type"] == "application/x-www-form-urlencoded" + sent = {k: v[0] for k, v in parse_qs(request.content.decode()).items()} + assert sent["client_id"] == "cid" + + +@pytest.mark.asyncio +@respx.mock +async def test_async_get_token_token_exchange_form_body() -> None: + route = respx.post("https://auth.example.com/oauth/token").mock( + return_value=Response( + 200, + json={ + "access_token": "delegated", + "token_type": "Bearer", + "expires_in": 300, + "scope": "read:docs", + }, + ) + ) + async with AsyncAuthorizerClient("cid", "https://auth.example.com") as c: + out = await c.get_token( + t.GetTokenRequest( + grant_type=t.GRANT_TYPE_TOKEN_EXCHANGE, + client_secret="agent-secret", + subject_token="user-tok", + subject_token_type=t.TOKEN_TYPE_ACCESS_TOKEN, + actor_token="agent-tok", + actor_token_type=t.TOKEN_TYPE_ACCESS_TOKEN, + resource="https://api.example.com", + ) + ) + assert out.access_token == "delegated" + assert out.token_type == "Bearer" + sent = {k: v[0] for k, v in parse_qs(route.calls[0].request.content.decode()).items()} + assert sent == { + "client_id": "cid", + "grant_type": t.GRANT_TYPE_TOKEN_EXCHANGE, + "client_secret": "agent-secret", + "subject_token": "user-tok", + "subject_token_type": t.TOKEN_TYPE_ACCESS_TOKEN, + "actor_token": "agent-tok", + "actor_token_type": t.TOKEN_TYPE_ACCESS_TOKEN, + "resource": "https://api.example.com", + } @pytest.mark.asyncio diff --git a/tests/test_client_authed.py b/tests/test_client_authed.py index ba3566f..176e1fb 100644 --- a/tests/test_client_authed.py +++ b/tests/test_client_authed.py @@ -1,5 +1,6 @@ # tests/test_client_authed.py import json +from urllib.parse import parse_qs import pytest import respx @@ -99,6 +100,14 @@ def test_list_permissions(): assert out.objects == ["document:1"] +def _form(route) -> dict[str, str]: + """Decode the single-valued urlencoded request body of the first call.""" + request = route.calls[0].request + assert request.headers["content-type"] == "application/x-www-form-urlencoded" + decoded = parse_qs(request.content.decode(), keep_blank_values=True) + return {k: v[0] for k, v in decoded.items()} + + @respx.mock def test_get_token_oauth_endpoint(): route = respx.post("https://auth.example.com/oauth/token").mock( @@ -109,10 +118,12 @@ def test_get_token_oauth_endpoint(): with _client() as c: out = c.get_token(t.GetTokenRequest(code="abc", code_verifier="ver")) assert out.access_token == "tok" - sent = json.loads(route.calls[0].request.content) - assert sent["client_id"] == "cid" - assert sent["grant_type"] == "authorization_code" - assert sent["code"] == "abc" + assert _form(route) == { + "client_id": "cid", + "grant_type": "authorization_code", + "code": "abc", + "code_verifier": "ver", + } def test_get_token_refresh_requires_token(): @@ -123,6 +134,108 @@ def test_get_token_refresh_requires_token(): c.get_token(t.GetTokenRequest(grant_type="refresh_token")) +@respx.mock +def test_get_token_client_credentials_form_body(): + route = respx.post("https://auth.example.com/oauth/token").mock( + return_value=Response( + 200, + json={ + "access_token": "mtok", + "token_type": "Bearer", + "expires_in": 900, + "scope": "read:users", + }, + ) + ) + with _client() as c: + out = c.get_token( + t.GetTokenRequest( + grant_type=t.GRANT_TYPE_CLIENT_CREDENTIALS, + client_secret="s3cret", + scope="read:users", + ) + ) + assert out.access_token == "mtok" + assert out.token_type == "Bearer" + assert out.scope == "read:users" + assert out.id_token == "" + assert _form(route) == { + "client_id": "cid", + "grant_type": "client_credentials", + "client_secret": "s3cret", + "scope": "read:users", + } + + +@respx.mock +def test_get_token_client_credentials_with_assertion(): + route = respx.post("https://auth.example.com/oauth/token").mock( + return_value=Response(200, json={"access_token": "mtok", "expires_in": 900}) + ) + with _client() as c: + c.get_token( + t.GetTokenRequest( + grant_type=t.GRANT_TYPE_CLIENT_CREDENTIALS, + client_assertion="jwt-svid", + client_assertion_type=t.CLIENT_ASSERTION_TYPE_JWT_BEARER, + ) + ) + sent = _form(route) + assert sent["client_assertion"] == "jwt-svid" + assert sent["client_assertion_type"] == t.CLIENT_ASSERTION_TYPE_JWT_BEARER + assert "client_secret" not in sent # only set params are sent + + +@respx.mock +def test_get_token_token_exchange_form_body(): + route = respx.post("https://auth.example.com/oauth/token").mock( + return_value=Response( + 200, + json={ + "access_token": "delegated", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 300, + "scope": "read:docs", + }, + ) + ) + with _client() as c: + out = c.get_token( + t.GetTokenRequest( + grant_type=t.GRANT_TYPE_TOKEN_EXCHANGE, + client_secret="agent-secret", + subject_token="user-tok", + subject_token_type=t.TOKEN_TYPE_ACCESS_TOKEN, + actor_token="agent-tok", + actor_token_type=t.TOKEN_TYPE_ACCESS_TOKEN, + resource="https://api.example.com", + scope="read:docs", + ) + ) + assert out.access_token == "delegated" + assert out.issued_token_type == "urn:ietf:params:oauth:token-type:access_token" + assert _form(route) == { + "client_id": "cid", + "grant_type": t.GRANT_TYPE_TOKEN_EXCHANGE, + "client_secret": "agent-secret", + "scope": "read:docs", + "subject_token": "user-tok", + "subject_token_type": t.TOKEN_TYPE_ACCESS_TOKEN, + "actor_token": "agent-tok", + "actor_token_type": t.TOKEN_TYPE_ACCESS_TOKEN, + "resource": "https://api.example.com", + } + + +def test_get_token_token_exchange_requires_subject_token(): + import pytest + + with _client() as c: + with pytest.raises(ValueError): + c.get_token(t.GetTokenRequest(grant_type=t.GRANT_TYPE_TOKEN_EXCHANGE)) + + @respx.mock def test_revoke_token(): route = respx.post("https://auth.example.com/oauth/revoke").mock(