From 9d5f54b9298221f8fbb2022917c4218aa9a437c0 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 11 Oct 2026 13:50:47 -0700 Subject: [PATCH] feat(auth): add JWT signing prerequisites for actor streams --- Cargo.lock | 6 + docs/content/docs/jwks.mdx | 69 ++ docs/content/docs/signing-service.mdx | 169 +++++ engine/artifacts/config-schema.json | 83 +++ engine/artifacts/errors/signing.conflict.json | 5 + .../errors/signing.invalid_input.json | 5 + .../artifacts/errors/signing.not_found.json | 5 + .../errors/signing.payload_too_large.json | 5 + .../artifacts/errors/signing.throttled.json | 5 + .../artifacts/errors/signing.unavailable.json | 5 + engine/artifacts/openapi.json | 465 +++++++++++++ .../api-builder/src/error_response.rs | 12 +- engine/packages/api-peer/Cargo.toml | 3 + engine/packages/api-peer/src/auth/issuers.rs | 100 +++ engine/packages/api-peer/src/auth/mod.rs | 5 + .../api-peer/src/auth/signing_quota.rs | 17 + .../api-peer/src/auth/webhook_signing.rs | 218 ++++++ engine/packages/api-peer/src/auth/webhooks.rs | 178 +++++ engine/packages/api-peer/src/lib.rs | 1 + engine/packages/api-peer/src/namespaces.rs | 6 + engine/packages/api-peer/src/router.rs | 22 +- .../api-peer/tests/unit/webhook_signing.rs | 152 +++++ .../api-public/src/auth/application_tokens.rs | 118 ++++ .../packages/api-public/src/auth/issuers.rs | 249 +++++++ engine/packages/api-public/src/auth/jwks.rs | 52 ++ engine/packages/api-public/src/auth/mod.rs | 10 + .../packages/api-public/src/auth/signing.rs | 160 +++++ .../api-public/src/auth/signing_audit.rs | 121 ++++ .../api-public/src/auth/strict_json.rs | 70 ++ .../api-public/src/auth/webhook_discovery.rs | 81 +++ .../api-public/src/auth/webhook_keys.rs | 210 ++++++ .../api-public/src/auth/webhook_signing.rs | 92 +++ engine/packages/api-public/src/ctx.rs | 2 + engine/packages/api-public/src/errors.rs | 2 + engine/packages/api-public/src/router.rs | 89 ++- engine/packages/api-public/tests/router.rs | 40 ++ .../api-public/tests/unit/signing_audit.rs | 137 ++++ .../api-public/tests/unit/signing_json.rs | 18 + .../tests/unit/webhook_management.rs | 32 + .../api-types/src/auth/application_tokens.rs | 22 + engine/packages/api-types/src/auth/issuers.rs | 65 ++ engine/packages/api-types/src/auth/mod.rs | 3 + .../packages/api-types/src/auth/webhooks.rs | 79 +++ engine/packages/auth-jwt/Cargo.toml | 5 + engine/packages/auth-jwt/src/application.rs | 190 ++++++ engine/packages/auth-jwt/src/codec.rs | 19 +- .../packages/auth-jwt/src/external_issuers.rs | 175 +++++ engine/packages/auth-jwt/src/grants.rs | 4 +- engine/packages/auth-jwt/src/jwks.rs | 53 ++ .../auth-jwt/src/key_ring_cache/external.rs | 625 ++++++++++++++++++ .../auth-jwt/src/key_ring_cache/mod.rs | 42 +- .../auth-jwt/src/key_ring_cache/signing.rs | 2 + engine/packages/auth-jwt/src/lib.rs | 9 +- engine/packages/auth-jwt/src/metrics.rs | 7 + .../auth-jwt/src/ops/external_issuers.rs | 210 ++++++ engine/packages/auth-jwt/src/ops/mod.rs | 4 + .../auth-jwt/src/ops/signing_quota.rs | 143 ++++ engine/packages/auth-jwt/src/ops/webhook.rs | 277 ++++++++ engine/packages/auth-jwt/src/storage_keys.rs | 49 +- engine/packages/auth-jwt/src/webhook.rs | 411 ++++++++++++ engine/packages/auth-jwt/src/workflows/mod.rs | 4 + .../src/workflows/webhook_recovery.rs | 89 +++ .../src/workflows/webhook_rotation.rs | 66 ++ engine/packages/auth-jwt/tests/grants.rs | 54 ++ .../auth-jwt/tests/unit/application.rs | 110 +++ .../auth-jwt/tests/unit/external_issuers.rs | 43 ++ engine/packages/auth-jwt/tests/unit/jwks.rs | 43 ++ .../tests/unit/key_ring_cache_external.rs | 516 +++++++++++++++ .../packages/auth-jwt/tests/unit/signing.rs | 5 + .../auth-jwt/tests/unit/signing_quota.rs | 42 ++ .../packages/auth-jwt/tests/unit/verifier.rs | 411 +++++++++++- .../packages/auth-jwt/tests/unit/webhook.rs | 159 +++++ .../auth-jwt/tests/unit/webhook_admission.rs | 10 + .../auth-jwt/tests/unit/webhook_recovery.rs | 125 ++++ engine/packages/auth-policy/src/authorize.rs | 33 +- engine/packages/auth-policy/src/errors.rs | 26 + engine/packages/auth-policy/src/lib.rs | 2 +- engine/packages/auth-policy/src/types.rs | 6 + .../auth-policy/tests/signing_namespace.rs | 48 ++ engine/packages/config/src/config/auth.rs | 60 ++ engine/packages/engine/Cargo.toml | 2 + engine/packages/engine/tests/jwt_auth.rs | 350 +++++++++- .../packages/engine/tests/purpose_signing.rs | 150 +++++ .../engine/tests/sdk_signing_bytes.rs | 38 ++ .../packages/guard/src/routing/api_public.rs | 35 +- engine/packages/guard/src/routing/mod.rs | 9 +- .../namespace/src/ops/exists_authoritative.rs | 29 + engine/packages/namespace/src/ops/mod.rs | 3 + .../src/ops/signing_recovery_page.rs | 48 ++ engine/packages/universaldb/src/utils/keys.rs | 1 + engine/sdks/api/fern/generators.yml | 4 +- .../go/api-full/auth_application_tokens.go | 11 + engine/sdks/go/api-full/auth_issuers.go | 15 + engine/sdks/go/api-full/auth_webhook_keys.go | 13 + .../api-full/authapplicationtokens/client.go | 67 ++ engine/sdks/go/api-full/authissuers/client.go | 184 ++++++ engine/sdks/go/api-full/authjwks/client.go | 64 ++ engine/sdks/go/api-full/authtokens/client.go | 64 +- .../api-full/authwebhookdiscovery/client.go | 104 +++ .../go/api-full/authwebhookkeys/client.go | 178 +++++ .../go/api-full/authwebhooksigning/client.go | 71 ++ engine/sdks/go/api-full/client/client.go | 321 +++++++-- engine/sdks/go/api-full/client/client_test.go | 18 +- engine/sdks/go/api-full/client/options.go | 42 +- engine/sdks/go/api-full/core/client_option.go | 44 -- engine/sdks/go/api-full/core/core.go | 59 +- engine/sdks/go/api-full/core/core_test.go | 68 +- .../sdks/go/api-full/core/request_option.go | 97 +++ engine/sdks/go/api-full/core/retrier.go | 166 +++++ engine/sdks/go/api-full/datacenters/client.go | 39 +- engine/sdks/go/api-full/envoys/client.go | 40 +- engine/sdks/go/api-full/health/client.go | 39 +- engine/sdks/go/api-full/metadata/client.go | 39 +- engine/sdks/go/api-full/namespaces/client.go | 65 +- .../sdks/go/api-full/option/request_option.go | 48 ++ engine/sdks/go/api-full/runners/client.go | 63 +- engine/sdks/go/api-full/types.go | 162 +++++ .../rust/api-full/.openapi-generator/FILES | 6 + engine/sdks/rust/api-full/README.md | 13 + engine/sdks/rust/api-full/docs/ActorName.md | 2 +- .../docs/AuthApplicationTokenCreateRequest.md | 15 + .../AuthApplicationTokenCreateResponse.md | 13 + .../api-full/docs/AuthApplicationTokensApi.md | 37 ++ engine/sdks/rust/api-full/docs/AuthIssuer.md | 16 + .../api-full/docs/AuthIssuerGetResponse.md | 11 + .../rust/api-full/docs/AuthIssuerGrant.md | 13 + .../api-full/docs/AuthIssuerListResponse.md | 11 + .../api-full/docs/AuthIssuerUpsertRequest.md | 15 + .../api-full/docs/AuthIssuerUpsertResponse.md | 11 + .../sdks/rust/api-full/docs/AuthIssuersApi.md | 128 ++++ engine/sdks/rust/api-full/docs/AuthJwksApi.md | 34 + .../api-full/docs/AuthWebhookDiscoveryApi.md | 66 ++ .../rust/api-full/docs/AuthWebhookJwks.md | 11 + .../api-full/docs/AuthWebhookKeyStatus.md | 15 + .../rust/api-full/docs/AuthWebhookKeysApi.md | 123 ++++ .../rust/api-full/docs/AuthWebhookMetadata.md | 15 + .../api-full/docs/AuthWebhookPublicKey.md | 16 + .../api-full/docs/AuthWebhookRevokeRequest.md | 13 + .../api-full/docs/AuthWebhookRotateRequest.md | 11 + .../api-full/docs/AuthWebhookSignResponse.md | 13 + .../api-full/docs/AuthWebhookSigningApi.md | 37 ++ engine/sdks/rust/api-full/docs/Envoy.md | 2 +- engine/sdks/rust/api-full/docs/Runner.md | 2 +- .../src/apis/auth_application_tokens_api.rs | 70 ++ .../api-full/src/apis/auth_issuers_api.rs | 256 +++++++ .../rust/api-full/src/apis/auth_jwks_api.rs | 63 ++ .../src/apis/auth_webhook_discovery_api.rs | 122 ++++ .../src/apis/auth_webhook_keys_api.rs | 246 +++++++ .../src/apis/auth_webhook_signing_api.rs | 72 ++ engine/sdks/rust/api-full/src/apis/mod.rs | 6 + .../rust/api-full/src/models/actor_name.rs | 4 +- .../auth_application_token_create_request.rs | 52 ++ .../auth_application_token_create_response.rs | 36 + .../rust/api-full/src/models/auth_issuer.rs | 48 ++ .../src/models/auth_issuer_get_response.rs | 26 + .../api-full/src/models/auth_issuer_grant.rs | 36 + .../src/models/auth_issuer_list_response.rs | 24 + .../src/models/auth_issuer_upsert_request.rs | 48 ++ .../src/models/auth_issuer_upsert_response.rs | 26 + .../api-full/src/models/auth_webhook_jwks.rs | 24 + .../src/models/auth_webhook_key_status.rs | 53 ++ .../src/models/auth_webhook_metadata.rs | 44 ++ .../src/models/auth_webhook_public_key.rs | 48 ++ .../src/models/auth_webhook_revoke_request.rs | 36 + .../src/models/auth_webhook_rotate_request.rs | 26 + .../src/models/auth_webhook_sign_response.rs | 34 + engine/sdks/rust/api-full/src/models/envoy.rs | 27 +- .../sdks/rust/api-full/src/models/runner.rs | 36 +- .../runner_config_kind_one_of_1_serverless.rs | 9 +- .../src/models/runner_config_response.rs | 4 +- engine/sdks/rust/data/build.rs | 2 + .../versioned/auth_jwt_external_issuers.rs | 35 + .../data/src/versioned/auth_signing_quota.rs | 35 + .../src/versioned/auth_webhook_key_ring.rs | 35 + engine/sdks/rust/data/src/versioned/mod.rs | 6 + .../data/auth.jwt_external_issuers.v1.bare | 14 + .../schemas/data/auth.signing_quota.v1.bare | 8 + .../data/auth.webhook_key_ring.v1.bare | 13 + engine/sdks/typescript/api-full/package.json | 133 ++-- engine/sdks/typescript/api-full/src/Client.ts | 19 +- .../authApplicationTokens/client/Client.ts | 115 ++++ .../authApplicationTokens/client/index.ts | 1 + .../AuthApplicationTokenCreateRequest.ts | 21 + .../client/requests/index.ts | 1 + .../resources/authApplicationTokens/index.ts | 1 + .../resources/authIssuers/client/Client.ts | 322 +++++++++ .../api/resources/authIssuers/client/index.ts | 1 + .../requests/AuthIssuerUpsertRequest.ts | 31 + .../authIssuers/client/requests/index.ts | 1 + .../src/api/resources/authIssuers/index.ts | 1 + .../api/resources/authJwks/client/Client.ts | 94 +++ .../api/resources/authJwks/client/index.ts | 1 + .../src/api/resources/authJwks/index.ts | 1 + .../api/resources/authTokens/client/Client.ts | 11 +- .../authWebhookDiscovery/client/Client.ts | 173 +++++ .../authWebhookDiscovery/client/index.ts | 1 + .../resources/authWebhookDiscovery/index.ts | 1 + .../authWebhookKeys/client/Client.ts | 311 +++++++++ .../resources/authWebhookKeys/client/index.ts | 1 + .../requests/AuthWebhookRevokeRequest.ts | 17 + .../requests/AuthWebhookRotateRequest.ts | 13 + .../authWebhookKeys/client/requests/index.ts | 2 + .../api/resources/authWebhookKeys/index.ts | 1 + .../authWebhookSigning/client/Client.ts | 110 +++ .../authWebhookSigning/client/index.ts | 1 + .../api/resources/authWebhookSigning/index.ts | 1 + .../resources/datacenters/client/Client.ts | 11 +- .../src/api/resources/envoys/client/Client.ts | 11 +- .../src/api/resources/health/client/Client.ts | 11 +- .../api/resources/metadata/client/Client.ts | 11 +- .../api/resources/namespaces/client/Client.ts | 11 +- .../api/resources/runners/client/Client.ts | 11 +- .../AuthApplicationTokenCreateResponse.ts | 9 + .../api-full/src/api/types/AuthIssuer.ts | 14 + .../src/api/types/AuthIssuerDeleteResponse.ts | 5 + .../src/api/types/AuthIssuerGetResponse.ts | 9 + .../api-full/src/api/types/AuthIssuerGrant.ts | 11 + .../src/api/types/AuthIssuerListResponse.ts | 9 + .../src/api/types/AuthIssuerUpsertResponse.ts | 9 + .../api-full/src/api/types/AuthWebhookJwks.ts | 9 + .../src/api/types/AuthWebhookKeyStatus.ts | 11 + .../src/api/types/AuthWebhookMetadata.ts | 13 + .../src/api/types/AuthWebhookPublicKey.ts | 12 + .../src/api/types/AuthWebhookSignResponse.ts | 11 + .../authApplicationTokens/client/index.ts | 1 + .../AuthApplicationTokenCreateRequest.ts | 28 + .../client/requests/index.ts | 1 + .../resources/authApplicationTokens/index.ts | 1 + .../resources/authIssuers/client/index.ts | 1 + .../requests/AuthIssuerUpsertRequest.ts | 29 + .../authIssuers/client/requests/index.ts | 1 + .../resources/authIssuers/index.ts | 1 + .../resources/authWebhookKeys/client/index.ts | 1 + .../requests/AuthWebhookRevokeRequest.ts | 24 + .../requests/AuthWebhookRotateRequest.ts | 20 + .../authWebhookKeys/client/requests/index.ts | 2 + .../resources/authWebhookKeys/index.ts | 1 + .../AuthApplicationTokenCreateResponse.ts | 24 + .../src/serialization/types/AuthIssuer.ts | 29 + .../types/AuthIssuerDeleteResponse.ts | 16 + .../types/AuthIssuerGetResponse.ts | 21 + .../serialization/types/AuthIssuerGrant.ts | 25 + .../types/AuthIssuerListResponse.ts | 21 + .../types/AuthIssuerUpsertResponse.ts | 21 + .../serialization/types/AuthWebhookJwks.ts | 19 + .../types/AuthWebhookKeyStatus.ts | 34 + .../types/AuthWebhookMetadata.ts | 29 + .../types/AuthWebhookPublicKey.ts | 30 + .../types/AuthWebhookSignResponse.ts | 24 + engine/sdks/typescript/data/package.json | 36 + .../data/src/auth-grants-v1.generated.ts | 275 ++++++++ engine/sdks/typescript/data/src/index.ts | 151 +++++ .../sdks/typescript/data/tests/auth-grants.ts | 113 ++++ engine/sdks/typescript/data/tsconfig.json | 9 + engine/sdks/typescript/data/tsup.config.ts | 4 + examples/jwks-counter/.gitignore | 3 + examples/jwks-counter/README.md | 30 + examples/jwks-counter/package.json | 25 + examples/jwks-counter/src/client.ts | 53 ++ examples/jwks-counter/src/config.ts | 19 + examples/jwks-counter/src/counter.ts | 14 + examples/jwks-counter/src/grants.ts | 40 ++ examples/jwks-counter/src/issuer.ts | 128 ++++ examples/jwks-counter/src/provision.ts | 72 ++ examples/jwks-counter/src/server.ts | 60 ++ examples/jwks-counter/tests/crypto.ts | 66 ++ examples/jwks-counter/tests/grants.ts | 20 + examples/jwks-counter/tests/server.ts | 58 ++ examples/jwks-counter/tests/smoke.ts | 106 +++ examples/jwks-counter/tsconfig.json | 14 + pnpm-lock.yaml | 25 + pnpm-workspace.yaml | 1 + scripts/fern/gen_inner.sh | 2 +- scripts/openapi/gen_rust.ts | 17 +- scripts/openapi/normalize.mjs | 31 + scripts/openapi/templates/rust/README.md | 6 + .../templates/rust/reqwest/api.mustache | 515 +++++++++++++++ scripts/openapi/tests/go/go.mod | 7 + scripts/openapi/tests/go/signing_test.go | 40 ++ scripts/openapi/tests/signing-bytes.ts | 24 + 280 files changed, 15833 insertions(+), 397 deletions(-) create mode 100644 docs/content/docs/jwks.mdx create mode 100644 docs/content/docs/signing-service.mdx create mode 100644 engine/artifacts/errors/signing.conflict.json create mode 100644 engine/artifacts/errors/signing.invalid_input.json create mode 100644 engine/artifacts/errors/signing.not_found.json create mode 100644 engine/artifacts/errors/signing.payload_too_large.json create mode 100644 engine/artifacts/errors/signing.throttled.json create mode 100644 engine/artifacts/errors/signing.unavailable.json create mode 100644 engine/packages/api-peer/src/auth/issuers.rs create mode 100644 engine/packages/api-peer/src/auth/mod.rs create mode 100644 engine/packages/api-peer/src/auth/signing_quota.rs create mode 100644 engine/packages/api-peer/src/auth/webhook_signing.rs create mode 100644 engine/packages/api-peer/src/auth/webhooks.rs create mode 100644 engine/packages/api-peer/tests/unit/webhook_signing.rs create mode 100644 engine/packages/api-public/src/auth/application_tokens.rs create mode 100644 engine/packages/api-public/src/auth/issuers.rs create mode 100644 engine/packages/api-public/src/auth/jwks.rs create mode 100644 engine/packages/api-public/src/auth/signing.rs create mode 100644 engine/packages/api-public/src/auth/signing_audit.rs create mode 100644 engine/packages/api-public/src/auth/strict_json.rs create mode 100644 engine/packages/api-public/src/auth/webhook_discovery.rs create mode 100644 engine/packages/api-public/src/auth/webhook_keys.rs create mode 100644 engine/packages/api-public/src/auth/webhook_signing.rs create mode 100644 engine/packages/api-public/tests/unit/signing_audit.rs create mode 100644 engine/packages/api-public/tests/unit/signing_json.rs create mode 100644 engine/packages/api-public/tests/unit/webhook_management.rs create mode 100644 engine/packages/api-types/src/auth/application_tokens.rs create mode 100644 engine/packages/api-types/src/auth/issuers.rs create mode 100644 engine/packages/api-types/src/auth/webhooks.rs create mode 100644 engine/packages/auth-jwt/src/application.rs create mode 100644 engine/packages/auth-jwt/src/external_issuers.rs create mode 100644 engine/packages/auth-jwt/src/jwks.rs create mode 100644 engine/packages/auth-jwt/src/key_ring_cache/external.rs create mode 100644 engine/packages/auth-jwt/src/ops/external_issuers.rs create mode 100644 engine/packages/auth-jwt/src/ops/signing_quota.rs create mode 100644 engine/packages/auth-jwt/src/ops/webhook.rs create mode 100644 engine/packages/auth-jwt/src/webhook.rs create mode 100644 engine/packages/auth-jwt/src/workflows/webhook_recovery.rs create mode 100644 engine/packages/auth-jwt/src/workflows/webhook_rotation.rs create mode 100644 engine/packages/auth-jwt/tests/unit/application.rs create mode 100644 engine/packages/auth-jwt/tests/unit/external_issuers.rs create mode 100644 engine/packages/auth-jwt/tests/unit/jwks.rs create mode 100644 engine/packages/auth-jwt/tests/unit/key_ring_cache_external.rs create mode 100644 engine/packages/auth-jwt/tests/unit/signing_quota.rs create mode 100644 engine/packages/auth-jwt/tests/unit/webhook.rs create mode 100644 engine/packages/auth-jwt/tests/unit/webhook_admission.rs create mode 100644 engine/packages/auth-jwt/tests/unit/webhook_recovery.rs create mode 100644 engine/packages/auth-policy/tests/signing_namespace.rs create mode 100644 engine/packages/engine/tests/purpose_signing.rs create mode 100644 engine/packages/engine/tests/sdk_signing_bytes.rs create mode 100644 engine/packages/namespace/src/ops/exists_authoritative.rs create mode 100644 engine/packages/namespace/src/ops/signing_recovery_page.rs create mode 100644 engine/sdks/go/api-full/auth_application_tokens.go create mode 100644 engine/sdks/go/api-full/auth_issuers.go create mode 100644 engine/sdks/go/api-full/auth_webhook_keys.go create mode 100644 engine/sdks/go/api-full/authapplicationtokens/client.go create mode 100644 engine/sdks/go/api-full/authissuers/client.go create mode 100644 engine/sdks/go/api-full/authjwks/client.go create mode 100644 engine/sdks/go/api-full/authwebhookdiscovery/client.go create mode 100644 engine/sdks/go/api-full/authwebhookkeys/client.go create mode 100644 engine/sdks/go/api-full/authwebhooksigning/client.go delete mode 100644 engine/sdks/go/api-full/core/client_option.go create mode 100644 engine/sdks/go/api-full/core/request_option.go create mode 100644 engine/sdks/go/api-full/core/retrier.go create mode 100644 engine/sdks/go/api-full/option/request_option.go create mode 100644 engine/sdks/rust/api-full/docs/AuthApplicationTokenCreateRequest.md create mode 100644 engine/sdks/rust/api-full/docs/AuthApplicationTokenCreateResponse.md create mode 100644 engine/sdks/rust/api-full/docs/AuthApplicationTokensApi.md create mode 100644 engine/sdks/rust/api-full/docs/AuthIssuer.md create mode 100644 engine/sdks/rust/api-full/docs/AuthIssuerGetResponse.md create mode 100644 engine/sdks/rust/api-full/docs/AuthIssuerGrant.md create mode 100644 engine/sdks/rust/api-full/docs/AuthIssuerListResponse.md create mode 100644 engine/sdks/rust/api-full/docs/AuthIssuerUpsertRequest.md create mode 100644 engine/sdks/rust/api-full/docs/AuthIssuerUpsertResponse.md create mode 100644 engine/sdks/rust/api-full/docs/AuthIssuersApi.md create mode 100644 engine/sdks/rust/api-full/docs/AuthJwksApi.md create mode 100644 engine/sdks/rust/api-full/docs/AuthWebhookDiscoveryApi.md create mode 100644 engine/sdks/rust/api-full/docs/AuthWebhookJwks.md create mode 100644 engine/sdks/rust/api-full/docs/AuthWebhookKeyStatus.md create mode 100644 engine/sdks/rust/api-full/docs/AuthWebhookKeysApi.md create mode 100644 engine/sdks/rust/api-full/docs/AuthWebhookMetadata.md create mode 100644 engine/sdks/rust/api-full/docs/AuthWebhookPublicKey.md create mode 100644 engine/sdks/rust/api-full/docs/AuthWebhookRevokeRequest.md create mode 100644 engine/sdks/rust/api-full/docs/AuthWebhookRotateRequest.md create mode 100644 engine/sdks/rust/api-full/docs/AuthWebhookSignResponse.md create mode 100644 engine/sdks/rust/api-full/docs/AuthWebhookSigningApi.md create mode 100644 engine/sdks/rust/api-full/src/apis/auth_application_tokens_api.rs create mode 100644 engine/sdks/rust/api-full/src/apis/auth_issuers_api.rs create mode 100644 engine/sdks/rust/api-full/src/apis/auth_jwks_api.rs create mode 100644 engine/sdks/rust/api-full/src/apis/auth_webhook_discovery_api.rs create mode 100644 engine/sdks/rust/api-full/src/apis/auth_webhook_keys_api.rs create mode 100644 engine/sdks/rust/api-full/src/apis/auth_webhook_signing_api.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_application_token_create_request.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_application_token_create_response.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_issuer.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_issuer_get_response.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_issuer_grant.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_issuer_list_response.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_issuer_upsert_request.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_issuer_upsert_response.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_webhook_jwks.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_webhook_key_status.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_webhook_metadata.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_webhook_public_key.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_webhook_revoke_request.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_webhook_rotate_request.rs create mode 100644 engine/sdks/rust/api-full/src/models/auth_webhook_sign_response.rs create mode 100644 engine/sdks/rust/data/src/versioned/auth_jwt_external_issuers.rs create mode 100644 engine/sdks/rust/data/src/versioned/auth_signing_quota.rs create mode 100644 engine/sdks/rust/data/src/versioned/auth_webhook_key_ring.rs create mode 100644 engine/sdks/schemas/data/auth.jwt_external_issuers.v1.bare create mode 100644 engine/sdks/schemas/data/auth.signing_quota.v1.bare create mode 100644 engine/sdks/schemas/data/auth.webhook_key_ring.v1.bare create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/Client.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/requests/AuthApplicationTokenCreateRequest.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/requests/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/Client.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/requests/AuthIssuerUpsertRequest.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/requests/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authIssuers/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authJwks/client/Client.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authJwks/client/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authJwks/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/client/Client.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/client/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/Client.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/AuthWebhookRevokeRequest.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/AuthWebhookRotateRequest.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/client/Client.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/client/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/index.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthApplicationTokenCreateResponse.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthIssuer.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthIssuerDeleteResponse.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthIssuerGetResponse.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthIssuerGrant.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthIssuerListResponse.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthIssuerUpsertResponse.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthWebhookJwks.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthWebhookKeyStatus.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthWebhookMetadata.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthWebhookPublicKey.ts create mode 100644 engine/sdks/typescript/api-full/src/api/types/AuthWebhookSignResponse.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/index.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/requests/AuthApplicationTokenCreateRequest.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/requests/index.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/index.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/index.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/requests/AuthIssuerUpsertRequest.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/requests/index.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/index.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/index.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/AuthWebhookRevokeRequest.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/AuthWebhookRotateRequest.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/index.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/index.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthApplicationTokenCreateResponse.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthIssuer.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerDeleteResponse.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerGetResponse.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerGrant.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerListResponse.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerUpsertResponse.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookJwks.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookKeyStatus.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookMetadata.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookPublicKey.ts create mode 100644 engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookSignResponse.ts create mode 100644 engine/sdks/typescript/data/package.json create mode 100644 engine/sdks/typescript/data/src/auth-grants-v1.generated.ts create mode 100644 engine/sdks/typescript/data/src/index.ts create mode 100644 engine/sdks/typescript/data/tests/auth-grants.ts create mode 100644 engine/sdks/typescript/data/tsconfig.json create mode 100644 engine/sdks/typescript/data/tsup.config.ts create mode 100644 examples/jwks-counter/.gitignore create mode 100644 examples/jwks-counter/README.md create mode 100644 examples/jwks-counter/package.json create mode 100644 examples/jwks-counter/src/client.ts create mode 100644 examples/jwks-counter/src/config.ts create mode 100644 examples/jwks-counter/src/counter.ts create mode 100644 examples/jwks-counter/src/grants.ts create mode 100644 examples/jwks-counter/src/issuer.ts create mode 100644 examples/jwks-counter/src/provision.ts create mode 100644 examples/jwks-counter/src/server.ts create mode 100644 examples/jwks-counter/tests/crypto.ts create mode 100644 examples/jwks-counter/tests/grants.ts create mode 100644 examples/jwks-counter/tests/server.ts create mode 100644 examples/jwks-counter/tests/smoke.ts create mode 100644 examples/jwks-counter/tsconfig.json create mode 100644 scripts/openapi/normalize.mjs create mode 100644 scripts/openapi/templates/rust/README.md create mode 100644 scripts/openapi/templates/rust/reqwest/api.mustache create mode 100644 scripts/openapi/tests/go/go.mod create mode 100644 scripts/openapi/tests/go/signing_test.go create mode 100644 scripts/openapi/tests/signing-bytes.ts diff --git a/Cargo.lock b/Cargo.lock index 65dc1f5f05..c232ae9b67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5174,6 +5174,8 @@ dependencies = [ "rivet-api-builder", "rivet-api-types", "rivet-api-util", + "rivet-auth-jwt", + "rivet-auth-policy", "rivet-config", "rivet-error", "rivet-pools", @@ -5344,7 +5346,9 @@ dependencies = [ "hex", "jsonwebtoken", "lazy_static", + "namespace", "rand 0.8.5", + "reqwest 0.12.22", "rivet-auth-policy", "rivet-config", "rivet-data", @@ -5359,6 +5363,7 @@ dependencies = [ "tokio", "tracing", "universaldb", + "url", "vbare", "zeroize", ] @@ -5620,6 +5625,7 @@ dependencies = [ "portpicker", "rand 0.8.5", "reqwest 0.12.22", + "rivet-api-full", "rivet-api-peer", "rivet-api-public", "rivet-api-types", diff --git a/docs/content/docs/jwks.mdx b/docs/content/docs/jwks.mdx new file mode 100644 index 0000000000..d81b14b6b1 --- /dev/null +++ b/docs/content/docs/jwks.mdx @@ -0,0 +1,69 @@ +--- +title: "JWKS and Public Keys" +description: "Discover Rivet signing keys and verify tokens and webhooks locally." +--- + +Rivet publishes public signing keys as a JSON Web Key Set (JWKS). Your application can verify signatures locally without sending the token or webhook back to Rivet. Configure a trusted discovery URL in your application; never choose that URL from an unverified token. + +Signing and namespace issuer configuration require a deployment with ACL authentication enabled. + +## Choose the right key set + +| Signed artifact | Discovery URL | Verification purpose | +| --- | --- | --- | +| Rivet access token | `/.well-known/jwks.json` | Access to the control plane | +| Downstream application token | `/.well-known/jwks.json` | Your application's own authorization | +| Namespace webhook | `/namespaces/{namespace_id}/auth/webhooks/jwks.json` | Webhooks from one immutable namespace | + +Resolve these paths against your configured control-plane endpoint, for example `https://api.rivet.dev`. The webhook path requires the immutable namespace ID, not its display name. Webhook keys are separate from token keys and from other namespaces' keys. + +Public discovery requires no bearer token. Webhook keys must first be provisioned using the authenticated `POST /auth/webhooks/ensure` operation described in [Signing Service](/actors/docs/signing-service). Public discovery never creates keys and returns 404 before provisioning. + +A webhook key has exactly these public fields: + +```json +{ + "kty": "OKP", + "crv": "Ed25519", + "kid": "ds_", + "use": "sig", + "alg": "EdDSA", + "x": "" +} +``` + +The JWKS wraps these objects in a `keys` array. Private key material is never included. Webhook IDs use the `ds_` prefix followed by the unpadded-base64url SHA-256 thumbprint of the canonical public JWK fields `crv`, `kty`, and `x`. + +`GET /namespaces/{namespace_id}/auth/webhooks` returns the active signer's `kid`, algorithm `ed25519`, `jwks_url`, immutable `namespace_id`, and ring `generation`. Metadata describes the signer at the time of the read. Always select the verification key using the `kid` on the actual token or webhook. + +## Verify the intended artifact + +A valid cryptographic signature alone does not grant permission. + +For application JWTs, require `alg=EdDSA`, `typ=rivet-app+jwt`, supported `rivet_ver=1`, and your configured issuer, audience, purpose, and immutable namespace. Check `iat` and `exp`, a positive lifetime within your application's limit, and your application claims against current application state. Rivet access tokens use a separate `rivet-auth+jwt` profile. Application tokens cannot authenticate to the control plane. + +For webhooks, verify the exact timestamp and body bytes using Ed25519, require a timestamp within five minutes, and enforce your application's replay and authorization rules. See the [byte format and examples](/actors/docs/signing-service#sign-a-webhook). + +If a `kid` is unfamiliar, fetch a fresh key set once with bounded retries and rate limiting. Reject the artifact if the key is still absent. Use ordinary cryptographic libraries; Rivet does not provide a verification RPC or a verification helper in its SDKs. + +## Rotation and caching + +Webhook keys rotate automatically. By default, a pending public key is published ten minutes before use, normal rotation occurs at seven days, and a key stops signing after fourteen days even if rotation stalls. A normally retired public key remains available for 365 seconds, covering timestamp freshness, clock allowance, and outstanding signing operations. + +For example, while key A signs deliveries, discovery starts publishing A and pending key B. After the publication interval, B becomes active. Discovery retains A briefly so a delivery already signed by A can still be verified. After that retention window, only the current and any newly pending keys remain. + +Rivet webhook discovery and active-signer metadata use `Cache-Control: no-store` and authoritative reads. Authentication/application-token discovery has its own HTTP cache policy; honor its returned headers. Do not reuse a webhook key set as a token key set or extend stale data indefinitely after a fetch failure. + +Emergency revocation removes a compromised key from authoritative discovery and atomically replaces the active signer. An operation already admitted may finish within the five-second signing lease. A verifier with cached public keys can continue accepting signatures until its cache refreshes: revocation cannot recall an offline copy of a public key. A replacement key may appear without the normal prepublication interval, so unfamiliar-key refresh matters. + +## Durable Streams discovery + +A Durable Streams integration preserves its own `{stream-root}/__ds/jwks.json` URL and subscription signing metadata. Its adapter fetches the namespace's dedicated webhook keys from Rivet and serves the existing `Cache-Control: public, max-age=300` contract. It must use a fresh authoritative snapshot when creating that response, account for retrieval age, and avoid additional stale caches. + +This five-minute downstream cache bounds how quickly receivers learn about revocation, in addition to outstanding read/signing leases and clock allowance. The delivery header's `kid` takes precedence over advisory subscription metadata. See the [Durable Streams webhook protocol](https://github.com/durable-streams/durable-streams/blob/461b40267aabd644558f9b19dbb9507dd5f691cf/PROTOCOL.md#71-webhook-delivery-and-callback). + +## External namespace issuers + +You can also configure a namespace to trust tokens from an external issuer using `/namespaces/{namespace}/auth/issuers/{issuer_name}`. This is a separate trust direction: Rivet fetches that issuer's public keys and applies the configured audience and grant ceiling before authorizing control-plane operations. + +An external issuer cannot turn a Rivet application token into a control-plane credential. Keep external issuer URLs, audiences, and grants narrowly scoped, and remove trust when the integration is retired. diff --git a/docs/content/docs/signing-service.mdx b/docs/content/docs/signing-service.mdx new file mode 100644 index 0000000000..047fbc729c --- /dev/null +++ b/docs/content/docs/signing-service.mdx @@ -0,0 +1,169 @@ +--- +title: "Signing Service" +description: "Issue downstream application tokens and sign exact webhook bytes with namespace-scoped permissions." +--- + +Use Rivet to issue application JWTs or sign webhook payloads while keeping private keys in the control plane. Receivers verify signatures locally using [public-key discovery](/actors/docs/jwks). + +The two operations have separate formats and permissions: + +| Operation | Endpoint | Required ACL resource and operation | +| --- | --- | --- | +| Issue an application token | `POST /auth/application-tokens` | `application_token/create` | +| Provision webhook keys | `POST /auth/webhooks/ensure` | `webhook_signing/create` | +| Sign a webhook | `POST /auth/webhooks/sign` | `webhook_signing/create` | + +These operations require ACL credentials. The credential must grant the requested resource's `create` operation with target `any` in exactly one concrete namespace. Duplicate grants for that namespace are fine; wildcard-only or multiple eligible namespaces are rejected. The request body cannot select a namespace. Rivet access JWTs and application JWTs cannot call these operations. + +This remote-signing model resembles [Cloud KMS asymmetric signing](https://docs.cloud.google.com/kms/docs/reference/rest/v1/projects.locations.keyRings.cryptoKeys.cryptoKeyVersions/asymmetricSign): a service holds the private key and callers verify with public keys. Rivet's API owns the token and webhook formats; it does not offer arbitrary-message or digest signing, private-key export, or an HSM custody guarantee. + +## Provision a signing credential + +An administrator can apply this ACL manifest with `POST /acl/manifest`, replacing `` with the immutable namespace ID: + +```json +{ + "rules": { + "application-signing": { + "namespace": {"id": ""}, + "resource": "application_token", + "target": "any", + "operations": ["create"] + }, + "webhook-signing": { + "namespace": {"id": ""}, + "resource": "webhook_signing", + "target": "any", + "operations": ["create"] + } + }, + "policies": {"signing": {"rules": ["application-signing", "webhook-signing"]}}, + "roles": {"signing": {"policies": ["signing"]}} +} +``` + +Create a credential with `POST /acl/tokens` and `{"roles":["signing"],"duration":86400}`. Keep the returned token secret and refresh it before expiry. Use separate roles if callers should have only one signing purpose. New signing resources become available after all control-plane readers support the updated ACL schema; an incomplete rollout returns 503. + +The examples below assume `RIVET_ENDPOINT` is your control-plane URL and `RIVET_SIGNING_TOKEN` is this namespace-scoped ACL credential. + +## Issue an application token + +Save a request as `application-token.json`: + +```json +{ + "audience": "durable-streams", + "purpose": "durable-streams.callback", + "duration": 86400, + "claims": { + "durable_streams": { + "root": "https://streams.gameinc.io/streams", + "subscription_id": "sub-1", + "wake_id": "wake-1", + "generation": 7, + "operations": ["callback"] + } + } +} +``` + +```bash +curl --fail-with-body "$RIVET_ENDPOINT/auth/application-tokens" \ + -H "Authorization: Bearer $RIVET_SIGNING_TOKEN" \ + -H 'Content-Type: application/json' \ + --data-binary @application-token.json +``` + +The response contains `token`, `issued_ts`, and `expires_ts`; timestamps are Unix milliseconds. The default lifetime is one hour, with a maximum of 24 hours or the configured lower maximum. Expiration is also bounded by the caller's credential. Always use the returned `expires_ts`; requesting 24 hours does not guarantee 24 hours remain. + +Rivet controls `iss`, `rivet_ns`, `iat`, `exp`, `jti`, version, and token type. Your audience must be nonempty and different from the control-plane audience. Purpose is required and cannot begin with the reserved `rivet` or `engine` prefixes, case-insensitively. An optional `subject` maps to `sub`. + +Application data stays under `app`. Supply at most eight named objects, with ASCII names of 1–64 characters drawn from letters, digits, `_`, `-`, and `.`. Registered JWT names and the reserved prefixes cannot be application namespace names. Claims are limited to 3,072 serialized UTF-8 bytes and eight container levels; the final compact JWT must fit 6,144 bytes. Unknown top-level request fields, duplicate JSON members, and attempts to override reserved fields are rejected. + +Application tokens are downstream-only. The control plane rejects them as credentials even if their application data resembles permissions. Receivers must validate the complete [application-token profile](/actors/docs/jwks#verify-the-intended-artifact), then apply their own authorization checks against current state. + +## Sign a webhook + +Provision the namespace's keys before advertising subscription metadata: + +```bash +curl --fail-with-body -X POST "$RIVET_ENDPOINT/auth/webhooks/ensure" \ + -H "Authorization: Bearer $RIVET_SIGNING_TOKEN" +``` + +This idempotent operation returns `{namespace_id, alg, kid, jwks_url, generation}` after the public keys are available. Normal signing also provisions on first use. + +Serialize the payload once to `payload.bin`, sign that file, and send those same bytes to the receiver: + +```bash +curl --fail-with-body "$RIVET_ENDPOINT/auth/webhooks/sign" \ + -H "Authorization: Bearer $RIVET_SIGNING_TOKEN" \ + -H 'Content-Type: application/octet-stream' \ + --data-binary @payload.bin +``` + +The response is: + +```json +{"timestamp":"1790300000","kid":"ds_","signature":""} +``` + +Construct the delivery header without changing the payload: + +```text +Webhook-Signature: t=,kid=,ed25519= +``` + +Rivet generates decimal Unix seconds and signs `ASCII(timestamp) + "." + raw_body` with plain Ed25519. The signature is 64 bytes before unpadded base64url encoding. JSON whitespace, line endings, NUL bytes, and non-UTF-8 bytes are significant. Do not parse and reserialize the body between signing and delivery. Compressed uploads are rejected; omit `Content-Encoding` or use `identity`. + +Receivers select the public key by `kid`, verify those exact bytes, reject timestamps outside a five-minute freshness window, and enforce replay protection using application state. Signing does not deliver the webhook or prove that the receiver accepted it. Obtain a new signature for each delivery retry. + +## Limits and failures + +| Limit | Default | +| --- | --- | +| Application request body | 16 KiB | +| Application JWT lifetime | 1 hour; maximum 24 hours | +| Serialized application claims / final JWT | 3,072 / 6,144 bytes | +| Webhook body | 32 MiB | +| Upload deadline | 30 seconds | +| Application issuance attempts per namespace | 300 per 60-second window | +| Webhook signing/provisioning attempts per namespace | 60 per 60-second window | +| Webhook input per namespace | 128 MiB per 60-second window | + +Namespace quotas are shared across API replicas and datacenters. Failed and cancelled attempts remain charged. Webhook bytes are reserved in 1 MiB increments, rounded up per request. Fixed windows allow bursts up to the configured allowance; apply caller-side pacing where smooth throughput matters. Process admission and buffered-memory limits may reject earlier under load. + +Configure `auth.jwt.signing.payload_bytes` between 1 byte and 128 MiB. Configure `auth.jwt.signing.defaults` with `application_operations`, `webhook_operations`, and `webhook_bytes`; `auth.jwt.signing.namespaces` maps immutable namespace IDs to complete quota overrides. These are per-minute allowances, not guaranteed throughput. Lowering a quota does not reset already consumed budget. + +Errors use Rivet's `{group, code, message}` envelope: + +| Status | Meaning | Caller action | +| --- | --- | --- | +| 400 | Invalid claims, audience, purpose, lifetime, or encoding | Correct the request | +| 401 | Missing, expired, or invalid credential | Refresh the credential | +| 403 | Missing permission or ambiguous namespace | Correct the ACL grants | +| 404 | Namespace or signing keys unavailable at that path | Check namespace and provision keys | +| 409 | Stale key generation or conflicting emergency request ID | Fetch key status and reconcile | +| 413 | Payload, claims, or final token exceeds its limit | Reduce input or adjust the permitted payload limit | +| 429 | Namespace quota or process capacity exhausted | Honor `Retry-After` and back off | +| 503 | Signing disabled, unavailable, or awaiting schema rollout | Retry with bounded backoff | + +No failure returns a partial signature. Signing responses use `Cache-Control: no-store`. Audit events record caller ID, namespace, purpose class, key version, outcome, and latency; metrics use bounded labels. Audit logging is operational observability, not a durable compliance ledger. Do not log credentials, tokens, claims, payloads, or signature headers in your application. + +## Operate webhook keys + +Normal rotation is automatic. Operators can inspect `GET /namespaces/{namespace}/auth/webhook-keys` with `webhook_signing/read`, rotate using `POST .../rotate` with `webhook_signing/update`, or revoke using `POST .../revoke` with `webhook_signing/delete`. These management paths use the namespace name and independently check authorization. + +Rotation accepts `{"expected_generation":7}` and stages a pending public key before activation. Revocation accepts `{"request_id":"","expected_generation":7,"kids":["ds_"]}`. Both return 200 only after the mutation commits, with public status including `generation`, `active_kid`, `pending_kid`, `last_emergency_request_id`, and `last_emergency_generation`. + +Every emergency revocation replaces the active signer and discards the pending signer, even when the requested key was already retiring. Repeating the last committed emergency request with the same arguments returns its receipt; conflicting reuse returns 409. This is bounded last-request idempotency. After later changes or an uncertain 503 response, inspect status before deciding to submit another mutation. + +Revocation remains available when new issuance is paused. Read the [caching and revocation limits](/actors/docs/jwks#rotation-and-caching) before treating revocation as an immediate receiver-side cutoff. + +## Durable Streams integration + +Preserve the stream-root JWKS endpoint, `ds_` key IDs, six-field public JWK shape, subscription metadata, and delivery header format. Use separate downstream purposes for callback and claim capabilities. Verify root, namespace, subscription, wake, generation, operation scope, and live subscription/lease state; a valid JWT does not bypass those checks. + +The default webhook limit accommodates the tested 4,096-stream snapshot. Repeated additions and glob subscriptions can produce larger snapshots. Detect oversize and report an actionable delivery failure; never truncate the snapshot or acknowledge undelivered streams. + +At migration, issue only Rivet artifacts. Keep legacy capability verification until the last previously issued capability expires, at most 24 hours, and retain old webhook public keys for 365 seconds after the final local signature. Then remove legacy key material. This preserves in-flight subscriptions while moving all new signing to Rivet. diff --git a/engine/artifacts/config-schema.json b/engine/artifacts/config-schema.json index f478f387b7..676455e775 100644 --- a/engine/artifacts/config-schema.json +++ b/engine/artifacts/config-schema.json @@ -255,6 +255,15 @@ "key_publish_lead": null, "key_rotation_interval": null, "max_duration": null, + "signing": { + "defaults": { + "application_operations": 300, + "webhook_bytes": 134217728, + "webhook_operations": 60 + }, + "namespaces": {}, + "payload_bytes": 33554432 + }, "unknown_kid_refresh_cooldown": null, "verifier_cache_ttl": null, "verifier_max_stale": null @@ -1011,6 +1020,23 @@ "format": "uint64", "minimum": 0.0 }, + "signing": { + "description": "Purpose-specific signing limits, shared by all replicas in a namespace.", + "default": { + "defaults": { + "application_operations": 300, + "webhook_bytes": 134217728, + "webhook_operations": 60 + }, + "namespaces": {}, + "payload_bytes": 33554432 + }, + "allOf": [ + { + "$ref": "#/definitions/Signing" + } + ] + }, "unknown_kid_refresh_cooldown": { "type": [ "integer", @@ -1965,6 +1991,63 @@ "Secret": { "type": "string" }, + "Signing": { + "description": "Fixed 60-second namespace budgets. Failed and cancelled attempts remain charged.", + "type": "object", + "properties": { + "defaults": { + "default": { + "application_operations": 300, + "webhook_bytes": 134217728, + "webhook_operations": 60 + }, + "allOf": [ + { + "$ref": "#/definitions/SigningQuota" + } + ] + }, + "namespaces": { + "description": "Overrides indexed by immutable namespace ID, never namespace name.", + "default": {}, + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/SigningQuota" + } + }, + "payload_bytes": { + "default": 33554432, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "SigningQuota": { + "type": "object", + "properties": { + "application_operations": { + "default": 300, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "webhook_bytes": { + "default": 134217728, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "webhook_operations": { + "default": 60, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, "Sqlite": { "type": "object", "properties": { diff --git a/engine/artifacts/errors/signing.conflict.json b/engine/artifacts/errors/signing.conflict.json new file mode 100644 index 0000000000..85f154a70e --- /dev/null +++ b/engine/artifacts/errors/signing.conflict.json @@ -0,0 +1,5 @@ +{ + "code": "conflict", + "group": "signing", + "message": "The signing key generation or request ID conflicts with current state." +} \ No newline at end of file diff --git a/engine/artifacts/errors/signing.invalid_input.json b/engine/artifacts/errors/signing.invalid_input.json new file mode 100644 index 0000000000..e43eb5e129 --- /dev/null +++ b/engine/artifacts/errors/signing.invalid_input.json @@ -0,0 +1,5 @@ +{ + "code": "invalid_input", + "group": "signing", + "message": "Invalid signing request." +} \ No newline at end of file diff --git a/engine/artifacts/errors/signing.not_found.json b/engine/artifacts/errors/signing.not_found.json new file mode 100644 index 0000000000..1651e1c638 --- /dev/null +++ b/engine/artifacts/errors/signing.not_found.json @@ -0,0 +1,5 @@ +{ + "code": "not_found", + "group": "signing", + "message": "The namespace signing keys have not been provisioned." +} \ No newline at end of file diff --git a/engine/artifacts/errors/signing.payload_too_large.json b/engine/artifacts/errors/signing.payload_too_large.json new file mode 100644 index 0000000000..fe17b2d639 --- /dev/null +++ b/engine/artifacts/errors/signing.payload_too_large.json @@ -0,0 +1,5 @@ +{ + "code": "payload_too_large", + "group": "signing", + "message": "The signing payload or token exceeds its size limit." +} \ No newline at end of file diff --git a/engine/artifacts/errors/signing.throttled.json b/engine/artifacts/errors/signing.throttled.json new file mode 100644 index 0000000000..679cc7ee60 --- /dev/null +++ b/engine/artifacts/errors/signing.throttled.json @@ -0,0 +1,5 @@ +{ + "code": "throttled", + "group": "signing", + "message": "The namespace signing quota or service capacity is exhausted." +} \ No newline at end of file diff --git a/engine/artifacts/errors/signing.unavailable.json b/engine/artifacts/errors/signing.unavailable.json new file mode 100644 index 0000000000..be8c0df7a5 --- /dev/null +++ b/engine/artifacts/errors/signing.unavailable.json @@ -0,0 +1,5 @@ +{ + "code": "unavailable", + "group": "signing", + "message": "The signing service is temporarily unavailable." +} \ No newline at end of file diff --git a/engine/artifacts/openapi.json b/engine/artifacts/openapi.json index aae3b626fe..2d27b5999e 100644 --- a/engine/artifacts/openapi.json +++ b/engine/artifacts/openapi.json @@ -458,6 +458,41 @@ ] } }, + "/auth/application-tokens": { + "post": { + "tags": [ + "auth::application_tokens" + ], + "operationId": "auth_application_tokens_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthApplicationTokenCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthApplicationTokenCreateResponse" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/auth/tokens": { "post": { "tags": [ @@ -518,6 +553,67 @@ ] } }, + "/auth/webhooks/ensure": { + "post": { + "tags": [ + "auth::webhook_keys" + ], + "operationId": "auth_webhooks_ensure", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookMetadata" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/auth/webhooks/sign": { + "post": { + "tags": [ + "auth::webhook_signing" + ], + "operationId": "auth_webhooks_sign", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookSignResponse" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/datacenters": { "get": { "tags": [ @@ -765,6 +861,375 @@ ] } }, + "/namespaces/{namespace}/auth/issuers": { + "get": { + "tags": [ + "auth::issuers" + ], + "operationId": "auth_issuers_list", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Namespace name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthIssuerListResponse" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/namespaces/{namespace}/auth/issuers/{issuer_name}": { + "get": { + "tags": [ + "auth::issuers" + ], + "operationId": "auth_issuers_get", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Namespace name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "issuer_name", + "in": "path", + "description": "Issuer registration name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthIssuerGetResponse" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "put": { + "tags": [ + "auth::issuers" + ], + "operationId": "auth_issuers_upsert", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Namespace name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "issuer_name", + "in": "path", + "description": "Issuer registration name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthIssuerUpsertRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthIssuerUpsertResponse" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + }, + "delete": { + "tags": [ + "auth::issuers" + ], + "operationId": "auth_issuers_delete", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Namespace name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "issuer_name", + "in": "path", + "description": "Issuer registration name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthIssuerDeleteResponse" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/namespaces/{namespace}/auth/webhook-keys": { + "get": { + "tags": [ + "auth::webhook_keys" + ], + "operationId": "auth_webhook_keys_get", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Namespace name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookKeyStatus" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/namespaces/{namespace}/auth/webhook-keys/revoke": { + "post": { + "tags": [ + "auth::webhook_keys" + ], + "operationId": "auth_webhook_keys_revoke", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Namespace name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookRevokeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookKeyStatus" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/namespaces/{namespace}/auth/webhook-keys/rotate": { + "post": { + "tags": [ + "auth::webhook_keys" + ], + "operationId": "auth_webhook_keys_rotate", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Namespace name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookRotateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookKeyStatus" + } + } + } + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/namespaces/{namespace}/auth/webhooks": { + "get": { + "tags": [ + "auth::webhook_discovery" + ], + "operationId": "auth_webhooks_metadata", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Immutable namespace ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookMetadata" + } + } + } + } + }, + "security": [] + } + }, + "/namespaces/{namespace}/auth/webhooks/jwks.json": { + "get": { + "tags": [ + "auth::webhook_discovery" + ], + "operationId": "auth_webhooks_jwks", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "Immutable namespace ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/jwk-set+json": { + "schema": { + "$ref": "#/components/schemas/AuthWebhookJwks" + } + } + } + } + }, + "security": [] + } + }, "/runner-configs": { "get": { "tags": [ diff --git a/engine/packages/api-builder/src/error_response.rs b/engine/packages/api-builder/src/error_response.rs index 00d9e29636..79a9bf99d7 100644 --- a/engine/packages/api-builder/src/error_response.rs +++ b/engine/packages/api-builder/src/error_response.rs @@ -22,7 +22,11 @@ impl IntoResponse for ApiError { let (status, error_response) = if let Some(rivet_err) = self.0.chain().find_map(|x| x.downcast_ref::()) { let status = match (rivet_err.group(), rivet_err.code()) { - ("api", "not_found") => StatusCode::NOT_FOUND, + ("api", "not_found") | ("signing", "not_found") => StatusCode::NOT_FOUND, + ("signing", "unavailable") => StatusCode::SERVICE_UNAVAILABLE, + ("signing", "payload_too_large") => StatusCode::PAYLOAD_TOO_LARGE, + ("signing", "throttled") => StatusCode::TOO_MANY_REQUESTS, + ("signing", "conflict") => StatusCode::CONFLICT, ("api", "unauthorized") => StatusCode::UNAUTHORIZED, ("api", "forbidden") => StatusCode::FORBIDDEN, ("auth", "invalid_token" | "token_expired") => StatusCode::UNAUTHORIZED, @@ -71,6 +75,12 @@ impl IntoResponse for ApiError { // Build response let mut res = (status, Json(error_response.clone())).into_response(); + if status == StatusCode::TOO_MANY_REQUESTS && error_response.group == "signing" { + res.headers_mut().insert( + axum::http::header::RETRY_AFTER, + axum::http::HeaderValue::from_static("60"), + ); + } // Add extension so we can reference it when logging response res.extensions_mut().insert(error_ext); diff --git a/engine/packages/api-peer/Cargo.toml b/engine/packages/api-peer/Cargo.toml index ef809eb7b4..39d56865e4 100644 --- a/engine/packages/api-peer/Cargo.toml +++ b/engine/packages/api-peer/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true license.workspace = true [dependencies] +acl.workspace = true anyhow.workspace = true axum.workspace = true base64.workspace = true @@ -21,6 +22,8 @@ pegboard.workspace = true rivet-api-builder.workspace = true rivet-api-types.workspace = true rivet-api-util.workspace = true +rivet-auth-jwt = { workspace = true, features = ["service"] } +rivet-auth-policy.workspace = true rivet-config.workspace = true rivet-error.workspace = true rivet-pools.workspace = true diff --git a/engine/packages/api-peer/src/auth/issuers.rs b/engine/packages/api-peer/src/auth/issuers.rs new file mode 100644 index 0000000000..e4500c174a --- /dev/null +++ b/engine/packages/api-peer/src/auth/issuers.rs @@ -0,0 +1,100 @@ +//! Leader-local registration mutations. The public API checks the caller before forwarding here. + +use anyhow::{Context, Result}; +use gas::prelude::*; +use rivet_api_builder::ApiCtx; +use rivet_api_types::auth::issuers::{Grant, Issuer, UpsertRequest}; +use rivet_auth_jwt::{ExternalIssuer, ValidatedGrantSet, ops::external_issuers::mutate}; +use rivet_auth_policy::{OwnedGrant, Scope}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MutationRequest { + pub namespace_id: Id, + pub name: String, + pub replacement: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MutationResponse { + pub issuer: Option, +} + +pub async fn mutate( + ctx: ApiCtx, + _path: (), + _query: (), + request: MutationRequest, +) -> Result { + let replacement = request + .replacement + .map(|entry| { + let grants = ValidatedGrantSet::new( + request.namespace_id, + entry.grant_ceiling.into_iter().map(|grant| OwnedGrant { + namespace: Scope::Id(request.namespace_id), + resource: grant.resource, + target: match grant.target { + acl::types::TargetScope::Any => Scope::Any, + acl::types::TargetScope::Id(id) => Scope::Id(id), + }, + operations: grant.operations, + }), + )?; + Ok::<_, anyhow::Error>(ExternalIssuer { + name: request.name.clone(), + issuer: entry.issuer, + audience: entry.audience, + jwks_url: entry.jwks_url, + algorithm: entry.algorithm, + grant_ceiling: grants.into_grants(), + }) + }) + .transpose()?; + let registry = ctx + .op(mutate::Input { + namespace_id: request.namespace_id, + name: request.name.clone(), + replacement: replacement.clone(), + }) + .await?; + tracing::info!( + namespace_id = %request.namespace_id, + issuer_name = %request.name, + generation = registry.generation, + action = if replacement.is_some() { "upsert" } else { "delete" }, + "JWT issuer registration mutation" + ); + let issuer = registry + .issuers + .into_iter() + .find(|entry| entry.name == request.name) + .map(into_response); + if replacement.is_some() { + Context::context(issuer.as_ref(), "issuer registration missing after commit")?; + } + Ok(MutationResponse { issuer }) +} + +pub fn into_response(entry: ExternalIssuer) -> Issuer { + Issuer { + name: entry.name, + issuer: entry.issuer, + audience: entry.audience, + jwks_url: entry.jwks_url, + algorithm: entry.algorithm, + grant_ceiling: entry + .grant_ceiling + .into_iter() + .map(|grant| Grant { + resource: grant.resource, + target: match grant.target { + Scope::Any => acl::types::TargetScope::Any, + Scope::Id(id) => acl::types::TargetScope::Id(id), + }, + operations: grant.operations, + }) + .collect(), + } +} diff --git a/engine/packages/api-peer/src/auth/mod.rs b/engine/packages/api-peer/src/auth/mod.rs new file mode 100644 index 0000000000..5dd4fe6e3d --- /dev/null +++ b/engine/packages/api-peer/src/auth/mod.rs @@ -0,0 +1,5 @@ +pub mod issuers; +pub mod webhook_signing; +pub mod webhooks; + +pub mod signing_quota; diff --git a/engine/packages/api-peer/src/auth/signing_quota.rs b/engine/packages/api-peer/src/auth/signing_quota.rs new file mode 100644 index 0000000000..2bd1acb394 --- /dev/null +++ b/engine/packages/api-peer/src/auth/signing_quota.rs @@ -0,0 +1,17 @@ +use anyhow::Result; +use rivet_api_builder::ApiCtx; +use rivet_auth_jwt::ops::signing_quota::Input; +#[tracing::instrument(skip_all)] +pub async fn charge(ctx: ApiCtx, _path: (), _query: (), input: Input) -> Result<()> { + ctx.op(input).await.map_err(|error| { + if error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .is_some_and(|e| e.group() == "signing") + { + error + } else { + rivet_auth_policy::errors::Signing::Unavailable.build() + } + }) +} diff --git a/engine/packages/api-peer/src/auth/webhook_signing.rs b/engine/packages/api-peer/src/auth/webhook_signing.rs new file mode 100644 index 0000000000..d44ac5f610 --- /dev/null +++ b/engine/packages/api-peer/src/auth/webhook_signing.rs @@ -0,0 +1,218 @@ +//! Exact-byte internal transport. No private keys or payloads enter workflow history. +use anyhow::Result; +use axum::{ + extract::Request, + response::{IntoResponse, Response}, +}; +use futures_util::StreamExt; +use gas::prelude::*; +use rivet_api_builder::{ + ApiCtx, ApiError, + extract::{Extension, Json, Path}, +}; +use rivet_api_types::auth::webhooks::SignResponse; +use rivet_auth_jwt::{ops::webhook as ops, webhook}; +use rivet_auth_policy::errors::Signing; +use std::sync::Arc; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +// Reservations include both the upload and the contiguous Ed25519 signing message. +// Holding the permit in the blocking task also bounds cancelled/in-flight signing work. +static BUFFER_BUDGET: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Arc::new(Semaphore::new(512))); +#[derive(serde::Serialize, serde::Deserialize)] +pub struct Signed { + pub response: SignResponse, + pub generation: u64, +} + +pub const DEADLINE_HEADER: &str = "x-rivet-signing-deadline-ms"; + +pub async fn read_body(request: Request) -> Result<(Vec, OwnedSemaphorePermit)> { + read_body_charged(request, webhook::MAX_PAYLOAD_BYTES, |_| async { Ok(()) }).await +} + +pub async fn read_body_charged( + request: Request, + cap: usize, + charge: F, +) -> Result<(Vec, OwnedSemaphorePermit)> +where + F: FnMut(u64) -> Fut, + Fut: std::future::Future>, +{ + read_body_observed(request, cap, charge, |_| {}).await +} + +pub async fn read_body_observed( + request: Request, + cap: usize, + mut charge: F, + mut observe: O, +) -> Result<(Vec, OwnedSemaphorePermit)> +where + F: FnMut(u64) -> Fut, + Fut: std::future::Future>, + O: FnMut(u64), +{ + if cap == 0 || cap > webhook::MAX_PAYLOAD_BYTES { + return Err(Signing::Unavailable.build()); + } + if request + .headers() + .get(axum::http::header::CONTENT_LENGTH) + .is_some_and(|h| { + h.to_str() + .ok() + .and_then(|s| s.parse::().ok()) + .is_none_or(|n| n > cap as u64) + }) { + return Err(Signing::PayloadTooLarge.build()); + } + if request + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + != Some("application/octet-stream") + || request + .headers() + .get(axum::http::header::CONTENT_ENCODING) + .is_some_and(|h| h != "identity") + { + return Err(Signing::InvalidInput.build()); + } + let permit = BUFFER_BUDGET + .clone() + .try_acquire_many_owned((2 * cap.div_ceil(1024 * 1024) + 2) as u32) + .map_err(|_| Signing::Throttled.build())?; + let body = tokio::time::timeout(std::time::Duration::from_secs(30), async move { + let mut stream = request.into_body().into_data_stream(); + let mut bytes = Vec::with_capacity(cap); + let mut reserved = 0usize; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| Signing::InvalidInput.build())?; + if chunk.len() > cap.saturating_sub(bytes.len()) { + return Err(Signing::PayloadTooLarge.build()); + } + let needed = bytes.len() + chunk.len(); + if needed > reserved { + let reservation = needed.div_ceil(1024 * 1024) * 1024 * 1024; + charge((reservation - reserved) as u64).await?; + reserved = reservation; + } + bytes.extend_from_slice(&chunk); + observe(bytes.len() as u64); + } + Ok(bytes) + }) + .await + .map_err(|_| Signing::Unavailable.build())??; + Ok((body, permit)) +} + +#[tracing::instrument(skip_all)] +pub async fn sign( + Extension(ctx): Extension, + Path((namespace_id,)): Path<(Id,)>, + request: Request, +) -> Response { + let result = async { + let deadline = request + .headers() + .get(DEADLINE_HEADER) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.parse::().ok()) + .ok_or_else(|| Signing::InvalidInput.build())?; + let cap = ctx.config().auth_required()?.jwt.signing.payload_bytes as usize; + let (body, permit) = read_body_charged(request, cap, |_| async { Ok(()) }).await?; + sign_body(&ctx, namespace_id, deadline, body, permit).await + } + .await; + let mut response = match result { + Ok(body) => Json(body).into_response(), + Err(error) => ApiError::from(error).into_response(), + }; + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-store"), + ); + response +} + +pub async fn sign_body( + ctx: &StandaloneCtx, + namespace_id: Id, + deadline: i64, + body: Vec, + permit: OwnedSemaphorePermit, +) -> Result { + if !ctx.config().is_leader() || !ctx.config().auth_required()?.jwt.issuance_enabled() { + return Err(Signing::Unavailable.build()); + } + if !ctx + .op(namespace::ops::exists_authoritative::Input { namespace_id }) + .await? + { + return Err(Signing::NotFound.build()); + } + let snapshot = ctx + .op(ops::get::Input { namespace_id }) + .await + .map_err(super::webhooks::map_error)? + .ok_or_else(|| Signing::NotFound.build())?; + let config_generation = ctx.config().auth_required()?.jwt.issuer_generation(); + if snapshot.ring.keys.leader_datacenter_id != ctx.config().dc_label() + || snapshot.ring.keys.leader_config_generation != config_generation + { + return Err(Signing::Unavailable.build()); + } + let read_started = snapshot.read_started; + let response = with_signing_permit(permit, move || { + let now = rivet_util::timestamp::now(); + if now >= deadline || read_started.elapsed() >= rivet_auth_jwt::SIGNING_CACHE_LEASE { + return Err(Signing::Unavailable.build()); + } + let response = + webhook::sign(&snapshot.ring, &body, now).map_err(|_| Signing::Unavailable.build())?; + let after = rivet_util::timestamp::now(); + if after < now + || after >= deadline + || after >= snapshot.ring.keys.active.sign_until_ts + || read_started.elapsed() >= rivet_auth_jwt::SIGNING_CACHE_LEASE + { + return Err(Signing::Unavailable.build()); + } + Ok(Signed { + generation: snapshot.ring.keys.generation, + response: SignResponse { + timestamp: response.timestamp, + kid: response.kid, + signature: response.signature, + }, + }) + }) + .await?; + // Scheduling pauses cannot extend the lease before sending the reply. + if read_started.elapsed() >= rivet_auth_jwt::SIGNING_CACHE_LEASE + || rivet_util::timestamp::now() >= deadline + { + return Err(Signing::Unavailable.build()); + } + Ok(response) +} + +#[cfg(test)] +#[path = "../../tests/unit/webhook_signing.rs"] +mod tests; + +async fn with_signing_permit( + permit: OwnedSemaphorePermit, + sign: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(move || { + let _permit = permit; + sign() + }) + .await + .map_err(|_| Signing::Unavailable.build())? +} diff --git a/engine/packages/api-peer/src/auth/webhooks.rs b/engine/packages/api-peer/src/auth/webhooks.rs new file mode 100644 index 0000000000..e16b35d068 --- /dev/null +++ b/engine/packages/api-peer/src/auth/webhooks.rs @@ -0,0 +1,178 @@ +//! Internal leader-local lifecycle API. Public handlers authenticate and authorize first. +use anyhow::Result; +use gas::prelude::*; +use rivet_api_builder::ApiCtx; +use rivet_api_types::auth::webhooks::KeyStatus; +use rivet_auth_jwt::{ + ops::webhook::{self, Change}, + webhook::{Error, Ring}, +}; +use rivet_auth_policy::errors::Signing; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MutationRequest { + pub namespace_id: Id, + pub change: Change, + #[serde(default)] + pub admission_deadline_ts: Option, +} + +#[tracing::instrument(skip_all)] +pub async fn mutate( + ctx: ApiCtx, + _path: (), + _query: (), + request: MutationRequest, +) -> Result { + if matches!( + request.change, + Change::Rotate { .. } | Change::Revoke { .. } + ) && request.admission_deadline_ts.is_none() + { + return Err(Signing::InvalidInput.build()); + } + webhook::mutate::check_admission(request.admission_deadline_ts, rivet_util::timestamp::now()) + .map_err(map_error)?; + if matches!( + request.change, + Change::Rotate { .. } | Change::Revoke { .. } + ) { + ctx.op(webhook::mutate::Input { + namespace_id: request.namespace_id, + change: Change::ReconcileTopology, + admission_deadline_ts: request.admission_deadline_ts, + }) + .await + .map_err(map_error)?; + } + if matches!( + request.change, + Change::Ensure | Change::Rotate { .. } | Change::Revoke { .. } + ) { + webhook::ensure_workflow(&ctx, request.namespace_id) + .await + .map_err(map_error)?; + } + let ring = ctx + .op(webhook::mutate::Input { + namespace_id: request.namespace_id, + change: request.change, + admission_deadline_ts: request.admission_deadline_ts, + }) + .await + .map_err(map_error)?; + Ok(status(&ring)) +} + +#[tracing::instrument(skip_all)] +pub async fn get(ctx: ApiCtx, (namespace_id,): (Id,), _query: ()) -> Result { + ctx.op(webhook::mutate::Input { + namespace_id, + change: Change::ReconcileTopology, + admission_deadline_ts: None, + }) + .await + .map_err(map_error)?; + webhook::ensure_workflow(&ctx, namespace_id) + .await + .map_err(map_error)?; + let snapshot = ctx + .op(webhook::get::Input { namespace_id }) + .await + .map_err(map_error)? + .ok_or_else(|| Signing::NotFound.build())?; + Ok(status(&snapshot.ring)) +} + +pub fn status(ring: &Ring) -> KeyStatus { + KeyStatus { + generation: ring.keys.generation, + active_kid: rivet_auth_jwt::webhook::public_kid(&ring.keys.active.key.public_key), + pending_kid: ring + .keys + .pending + .as_ref() + .map(|key| rivet_auth_jwt::webhook::public_kid(&key.key.public_key)), + last_emergency_request_id: ring + .last_emergency + .as_ref() + .map(|r| Uuid::from_bytes(r.request_id).to_string()), + last_emergency_generation: ring.last_emergency.as_ref().map(|r| r.committed_generation), + } +} + +pub fn map_error(error: anyhow::Error) -> anyhow::Error { + match error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + Some(Error::InvalidInput) => Signing::InvalidInput.build(), + Some(Error::NotFound) => Signing::NotFound.build(), + Some(Error::Conflict) => Signing::Conflict.build(), + _ => Signing::Unavailable.build(), + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Discovery { + pub jwks: rivet_api_types::auth::webhooks::Jwks, + pub metadata: Option, +} + +#[tracing::instrument(skip_all)] +pub async fn discovery(ctx: ApiCtx, (namespace_id,): (Id,), _query: ()) -> Result { + if !ctx + .op(namespace::ops::exists_authoritative::Input { namespace_id }) + .await? + { + return Err(Signing::NotFound.build()); + } + let snapshot = ctx + .op(webhook::get::Input { namespace_id }) + .await + .map_err(map_error)? + .ok_or_else(|| Signing::NotFound.build())?; + let ring = &snapshot.ring; + let now = rivet_util::timestamp::now(); + let current = now >= ring.keys.active.activated_ts + && now < ring.keys.active.sign_until_ts + && ring.keys.leader_datacenter_id == ctx.config().dc_label() + && ring.keys.leader_config_generation + == ctx.config().auth_required()?.jwt.issuer_generation(); + let base = &ctx.config().leader_dc()?.public_url; + let metadata = if current { + Some(rivet_api_types::auth::webhooks::Metadata { + namespace_id, + alg: "ed25519".into(), + kid: rivet_auth_jwt::webhook::public_kid(&ring.keys.active.key.public_key), + jwks_url: base + .join(&format!( + "namespaces/{namespace_id}/auth/webhooks/jwks.json" + ))? + .to_string(), + generation: ring.keys.generation, + }) + } else { + None + }; + let keys = ring + .public_keys(now) + .into_iter() + .map(|key| rivet_api_types::auth::webhooks::PublicKey { + kty: key.kty, + crv: key.crv, + kid: key.kid, + public_key_use: key.public_key_use, + alg: key.alg, + x: key.x, + }) + .collect(); + if snapshot.read_started.elapsed() >= rivet_auth_jwt::SIGNING_CACHE_LEASE { + return Err(Signing::Unavailable.build()); + } + Ok(Discovery { + jwks: rivet_api_types::auth::webhooks::Jwks { keys }, + metadata, + }) +} diff --git a/engine/packages/api-peer/src/lib.rs b/engine/packages/api-peer/src/lib.rs index 9fc1427869..310616e322 100644 --- a/engine/packages/api-peer/src/lib.rs +++ b/engine/packages/api-peer/src/lib.rs @@ -3,6 +3,7 @@ use std::net::SocketAddr; use anyhow::*; pub mod actors; +pub mod auth; pub mod depot_inspect; pub mod envoys; pub mod internal; diff --git a/engine/packages/api-peer/src/namespaces.rs b/engine/packages/api-peer/src/namespaces.rs index 9753446de7..62b71be802 100644 --- a/engine/packages/api-peer/src/namespaces.rs +++ b/engine/packages/api-peer/src/namespaces.rs @@ -135,3 +135,9 @@ pub async fn create( Ok(CreateResponse { namespace }) } + +#[tracing::instrument(skip_all)] +pub async fn exists_authoritative(ctx: ApiCtx, (namespace_id,): (Id,), _query: ()) -> Result { + ctx.op(namespace::ops::exists_authoritative::Input { namespace_id }) + .await +} diff --git a/engine/packages/api-peer/src/router.rs b/engine/packages/api-peer/src/router.rs index ccf9d99d6c..7f6574bfdd 100644 --- a/engine/packages/api-peer/src/router.rs +++ b/engine/packages/api-peer/src/router.rs @@ -1,6 +1,6 @@ use rivet_api_builder::{create_router, prelude::*}; -use crate::{actors, depot_inspect, envoys, internal, namespaces, runner_configs, runners}; +use crate::{actors, auth, depot_inspect, envoys, internal, namespaces, runner_configs, runners}; #[tracing::instrument(level = "debug", skip_all)] pub async fn router( @@ -10,8 +10,28 @@ pub async fn router( create_router("api-peer", config, pools, |mut router| { router = epoxy::http_routes::mount_routes(router); router + .route("/auth/issuers/mutate", post(auth::issuers::mutate)) + .route("/auth/signing-quota", post(auth::signing_quota::charge)) + .route("/auth/webhooks/mutate", post(auth::webhooks::mutate)) + .route( + "/auth/webhooks/{namespace_id}/status", + get(auth::webhooks::get), + ) + .route( + "/auth/webhooks/{namespace_id}/discovery", + get(auth::webhooks::discovery), + ) + // Raw byte forwarding must bypass the JSON wrapper. + .route( + "/auth/webhooks/{namespace_id}/sign", + axum::routing::post(auth::webhook_signing::sign), + ) // MARK: Namespaces .route("/namespaces", get(namespaces::list)) + .route( + "/namespaces/{namespace_id}/exists-authoritative", + get(namespaces::exists_authoritative), + ) .route("/namespaces", post(namespaces::create)) // MARK: Runner configs .route("/runner-configs", get(runner_configs::list)) diff --git a/engine/packages/api-peer/tests/unit/webhook_signing.rs b/engine/packages/api-peer/tests/unit/webhook_signing.rs new file mode 100644 index 0000000000..9a9be310c6 --- /dev/null +++ b/engine/packages/api-peer/tests/unit/webhook_signing.rs @@ -0,0 +1,152 @@ +use super::*; +static TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +#[tokio::test] +async fn raw_body_is_preserved_and_content_encoding_is_rejected() { + let _guard = TEST_LOCK.lock().await; + let body = vec![0, 255, b'\r', b'\n', b'{', b'}']; + let request = Request::builder() + .header("content-type", "application/octet-stream") + .body(axum::body::Body::from(body.clone())) + .unwrap(); + let (actual, permit) = read_body(request).await.unwrap(); + assert_eq!(actual, body); + drop(permit); + let request = Request::builder() + .header("content-type", "application/octet-stream") + .header("content-encoding", "gzip") + .body(axum::body::Body::empty()) + .unwrap(); + assert!(read_body(request).await.is_err()); +} + +#[tokio::test] +async fn unknown_length_upload_charges_before_acceptance_and_releases_capacity_on_failure() { + let _guard = TEST_LOCK.lock().await; + use std::sync::atomic::{AtomicU64, Ordering}; + let charged = AtomicU64::new(0); + let request = Request::builder() + .header("content-type", "application/octet-stream") + .body(axum::body::Body::from_stream(futures_util::stream::iter([ + Ok::<_, std::io::Error>(vec![0u8; 512 * 1024]), + Ok(vec![255; 512 * 1024 + 1]), + ]))) + .unwrap(); + let (bytes, permit) = read_body_charged(request, 2 * 1024 * 1024, |n| { + charged.fetch_add(n, Ordering::Relaxed); + async { Ok(()) } + }) + .await + .unwrap(); + assert_eq!(bytes.len(), 1024 * 1024 + 1); + assert_eq!(charged.load(Ordering::Relaxed), 2 * 1024 * 1024); + drop(permit); + let before = BUFFER_BUDGET.available_permits(); + let request = Request::builder() + .header("content-type", "application/octet-stream") + .body(axum::body::Body::from(vec![0u8; 1025])) + .unwrap(); + assert!( + read_body_charged(request, 1024, |_| async { Ok(()) }) + .await + .is_err() + ); + assert_eq!(BUFFER_BUDGET.available_permits(), before); + let request = Request::builder() + .header("content-type", "application/octet-stream") + .body(axum::body::Body::from("a")) + .unwrap(); + assert!( + read_body_charged(request, 1024, |_| async { Err(Signing::Throttled.build()) }) + .await + .is_err() + ); + assert_eq!(BUFFER_BUDGET.available_permits(), before); +} + +#[tokio::test] +async fn cancelling_an_upload_releases_its_memory_reservation() { + let _guard = TEST_LOCK.lock().await; + let started = std::sync::Arc::new(tokio::sync::Notify::new()); + let observed = started.clone(); + let request = Request::builder() + .header("content-type", "application/octet-stream") + .body(axum::body::Body::from_stream(futures_util::stream::once( + async move { + observed.notify_one(); + std::future::pending::, std::io::Error>>().await + }, + ))) + .unwrap(); + let before = BUFFER_BUDGET.available_permits(); + let task = tokio::spawn(read_body_charged(request, 1024 * 1024, |_| async { + Ok(()) + })); + started.notified().await; + assert!(BUFFER_BUDGET.available_permits() < before); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert_eq!(BUFFER_BUDGET.available_permits(), before); +} + +#[tokio::test] +async fn cancelling_a_signer_keeps_memory_reserved_until_blocking_work_stops() { + let _guard = TEST_LOCK.lock().await; + let before = BUFFER_BUDGET.available_permits(); + let permit = BUFFER_BUDGET.clone().try_acquire_many_owned(4).unwrap(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let task = tokio::spawn(with_signing_permit(permit, move || { + started_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + Ok(()) + })); + started_rx.await.unwrap(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert_eq!(BUFFER_BUDGET.available_permits(), before - 4); + release_tx.send(()).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while BUFFER_BUDGET.available_permits() != before { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn audit_keeps_accepted_bytes_when_a_later_quota_reservation_fails() { + let _guard = TEST_LOCK.lock().await; + use std::sync::atomic::{AtomicU64, Ordering}; + let observed = AtomicU64::new(0); + let calls = AtomicU64::new(0); + let request = Request::builder() + .header("content-type", "application/octet-stream") + .body(axum::body::Body::from_stream(futures_util::stream::iter([ + Ok::<_, std::io::Error>(vec![0; 512 * 1024]), + Ok(vec![255; 1024 * 1024]), + ]))) + .unwrap(); + let before = BUFFER_BUDGET.available_permits(); + let result = read_body_observed( + request, + 2 * 1024 * 1024, + |_| { + let allowed = calls.fetch_add(1, Ordering::Relaxed) == 0; + async move { + if allowed { + Ok(()) + } else { + Err(Signing::Throttled.build()) + } + } + }, + |bytes| { + observed.store(bytes, Ordering::Relaxed); + }, + ) + .await; + assert!(result.is_err()); + assert_eq!(observed.load(Ordering::Relaxed), 512 * 1024); + assert_eq!(BUFFER_BUDGET.available_permits(), before); +} diff --git a/engine/packages/api-public/src/auth/application_tokens.rs b/engine/packages/api-public/src/auth/application_tokens.rs new file mode 100644 index 0000000000..4c65c463a3 --- /dev/null +++ b/engine/packages/api-public/src/auth/application_tokens.rs @@ -0,0 +1,118 @@ +use super::signing; +use crate::{ctx::ApiCtx, errors::Signing}; +use anyhow::Result; +use axum::{ + extract::Request, + response::{IntoResponse, Response}, +}; +use rivet_api_builder::extract::{Extension, Json}; +use rivet_api_types::auth::application_tokens::{CreateRequest, CreateResponse}; +use rivet_auth::ResourceKind; +use rivet_auth_jwt::{TokenError, application}; + +#[utoipa::path( + post, operation_id = "auth_application_tokens_create", path = "/auth/application-tokens", + request_body(content = CreateRequest, content_type = "application/json"), + responses((status = 200, body = CreateResponse)), security(("bearer_auth" = [])), +)] +#[tracing::instrument(skip_all)] +pub async fn create(Extension(ctx): Extension, request: Request) -> Response { + signing::no_store(match create_inner(&ctx, request).await { + Ok(response) => Json(response).into_response(), + Err(error) => signing::error_response(error), + }) +} + +async fn create_inner(ctx: &ApiCtx, request: Request) -> Result { + let (namespace_id, _) = signing::credential(ctx, ResourceKind::ApplicationToken).await?; + signing::charge( + ctx, + namespace_id, + rivet_auth_jwt::ops::signing_quota::Charge::Application, + ) + .await?; + + if request + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .and_then(|v| v.split(';').next()) + .map(str::trim) + != Some("application/json") + || request + .headers() + .contains_key(axum::http::header::CONTENT_ENCODING) + { + return Err(Signing::InvalidInput.build()); + } + let bytes = tokio::time::timeout( + std::time::Duration::from_secs(30), + axum::body::to_bytes(request.into_body(), 16 * 1024), + ) + .await + .map_err(|_| Signing::Unavailable.build())? + .map_err(|_| Signing::PayloadTooLarge.build())?; + ctx.signing_audit.bytes(bytes.len() as u64); + let body: CreateRequest = super::strict_json::parse(&bytes)?; + let jwt = &ctx.config().auth_required()?.jwt; + application::validate_application_data( + &body.audience, + &body.purpose, + body.subject.as_deref(), + &body.claims, + jwt.audience(), + ) + .map_err(|error| { + if error == TokenError::TokenTooLarge { + Signing::PayloadTooLarge.build() + } else { + Signing::InvalidInput.build() + } + })?; + ctx.signing_audit.purpose(&body.purpose); + let duration = body.duration.unwrap_or(jwt.default_duration().as_secs()); + if duration == 0 || duration > jwt.max_duration().as_secs() { + return Err(Signing::InvalidInput.build()); + } + let admission_started = std::time::Instant::now(); + let (current_namespace, acl) = signing::credential(ctx, ResourceKind::ApplicationToken).await?; + if current_namespace != namespace_id { + return Err(rivet_auth::errors::Auth::InsufficientPermissions.build()); + } + let expires_no_later_than_ts = + signing::expiration(rivet_util::timestamp::now(), duration, acl.expires_ts())?; + let issued = ctx + .jwt_key_ring_cache()? + .issue_application( + &application::service::IssueRequest { + namespace_id, + audience: body.audience, + purpose: body.purpose, + subject: body.subject, + app: body.claims, + expires_no_later_than_ts, + }, + jwt.max_duration().as_secs(), + ) + .await + .map_err(|err| { + if err.downcast_ref::() == Some(&TokenError::TokenTooLarge) { + Signing::PayloadTooLarge.build() + } else { + Signing::Unavailable.build() + } + })?; + if admission_started.elapsed() >= rivet_auth_jwt::SIGNING_CACHE_LEASE + || acl + .expires_ts() + .is_some_and(|exp| rivet_util::timestamp::now() >= exp) + { + return Err(Signing::Unavailable.build()); + } + ctx.signing_audit.key(issued.kid, issued.generation); + Ok(CreateResponse { + token: issued.token, + issued_ts: issued.issued_ts, + expires_ts: issued.expires_ts, + }) +} diff --git a/engine/packages/api-public/src/auth/issuers.rs b/engine/packages/api-public/src/auth/issuers.rs new file mode 100644 index 0000000000..dfffd5108c --- /dev/null +++ b/engine/packages/api-public/src/auth/issuers.rs @@ -0,0 +1,249 @@ +//! Namespace-scoped management of trusted external JWT signers. + +use acl::types::TargetScope; +use anyhow::{Context, Result}; +use axum::{ + http::Method, + response::{IntoResponse, Json, Response}, +}; +use rivet_api_builder::{ + ApiBadRequest, ApiError, ApiNotFound, + extract::{Extension, Path}, +}; +use rivet_api_peer::auth::issuers::{MutationRequest, MutationResponse, into_response}; +use rivet_api_types::auth::issuers::{ + DeleteResponse, GetResponse, ListResponse, UpsertRequest, UpsertResponse, +}; +use rivet_api_util::request_remote_datacenter; +use rivet_auth::{AccessRequest, Consistency, OperationKind, OwnedGrant, ResourceKind, Scope}; +use rivet_auth_jwt::{ + ExternalIssuer, ExternalIssuerRegistry, ValidatedGrantSet, ops::external_issuers, +}; +use serde::Deserialize; + +use crate::ctx::ApiCtx; + +#[derive(Deserialize)] +pub struct NamespacePath { + pub namespace: String, +} + +#[derive(Deserialize)] +pub struct IssuerPath { + pub namespace: String, + pub issuer_name: String, +} + +#[utoipa::path( + get, operation_id = "auth_issuers_list", path = "/namespaces/{namespace}/auth/issuers", + params(("namespace" = String, Path, description = "Namespace name")), + responses((status = 200, body = ListResponse)), security(("bearer_auth" = [])), +)] +#[tracing::instrument(skip_all)] +pub async fn list(Extension(ctx): Extension, Path(path): Path) -> Response { + match list_inner(ctx, path).await { + Ok(result) => Json(result).into_response(), + Err(error) => ApiError::from(error).into_response(), + } +} + +async fn list_inner(ctx: ApiCtx, path: NamespacePath) -> Result { + let namespace_id = resolve_namespace(&ctx, path.namespace).await?; + let credential = ctx.authenticate(Consistency::Cached).await?; + credential.require_acl()?.authorize(&AccessRequest { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Jwt, + target: Scope::Any, + operation: OperationKind::List, + })?; + let registry = ctx + .op(external_issuers::get::Input { namespace_id }) + .await? + .registry; + Ok(ListResponse { + issuers: registry.issuers.into_iter().map(into_response).collect(), + }) +} + +#[utoipa::path( + get, operation_id = "auth_issuers_get", path = "/namespaces/{namespace}/auth/issuers/{issuer_name}", + params(("namespace" = String, Path, description = "Namespace name"), ("issuer_name" = String, Path, description = "Issuer registration name")), + responses((status = 200, body = GetResponse)), security(("bearer_auth" = [])), +)] +#[tracing::instrument(skip_all)] +pub async fn get(Extension(ctx): Extension, Path(path): Path) -> Response { + match get_inner(ctx, path).await { + Ok(result) => Json(result).into_response(), + Err(error) => ApiError::from(error).into_response(), + } +} + +async fn get_inner(ctx: ApiCtx, path: IssuerPath) -> Result { + let namespace_id = resolve_namespace(&ctx, path.namespace).await?; + let credential = ctx.authenticate(Consistency::Cached).await?; + credential.require_acl()?.authorize(&AccessRequest { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Jwt, + target: Scope::Any, + operation: OperationKind::Read, + })?; + let registry = ctx + .op(external_issuers::get::Input { namespace_id }) + .await? + .registry; + let issuer = registry + .issuers + .into_iter() + .find(|entry| entry.name == path.issuer_name) + .ok_or_else(|| ApiNotFound.build())?; + Ok(GetResponse { + issuer: into_response(issuer), + }) +} + +#[utoipa::path( + put, operation_id = "auth_issuers_upsert", path = "/namespaces/{namespace}/auth/issuers/{issuer_name}", + params(("namespace" = String, Path, description = "Namespace name"), ("issuer_name" = String, Path, description = "Issuer registration name")), + request_body(content = UpsertRequest, content_type = "application/json"), + responses((status = 200, body = UpsertResponse)), security(("bearer_auth" = [])), +)] +#[tracing::instrument(skip_all)] +pub async fn upsert( + Extension(ctx): Extension, + Path(path): Path, + rivet_api_builder::extract::Json(body): rivet_api_builder::extract::Json, +) -> Response { + match upsert_inner(ctx, path, body).await { + Ok(result) => Json(result).into_response(), + Err(error) => ApiError::from(error).into_response(), + } +} + +async fn upsert_inner( + ctx: ApiCtx, + path: IssuerPath, + body: UpsertRequest, +) -> Result { + let namespace_id = resolve_namespace(&ctx, path.namespace).await?; + let credential = ctx.authenticate(Consistency::Authoritative).await?; + let acl = credential.require_acl()?; + acl.authorize(&AccessRequest { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Jwt, + target: Scope::Any, + operation: OperationKind::Update, + })?; + let grants = ValidatedGrantSet::new( + namespace_id, + body.grant_ceiling.iter().cloned().map(|grant| OwnedGrant { + namespace: Scope::Id(namespace_id), + resource: grant.resource, + target: match grant.target { + TargetScope::Any => Scope::Any, + TargetScope::Id(id) => Scope::Id(id), + }, + operations: grant.operations, + }), + ) + .map_err(|_| { + ApiBadRequest { + reason: "invalid issuer grant ceiling".into(), + } + .build() + })?; + if !rivet_auth::can_delegate(acl.authority().grants(), grants.grants()) { + return Err(rivet_auth::errors::Auth::InsufficientPermissions.build()); + } + let entry = ExternalIssuer { + name: path.issuer_name.clone(), + issuer: body.issuer.clone(), + audience: body.audience.clone(), + jwks_url: body.jwks_url.clone(), + algorithm: body.algorithm.clone(), + grant_ceiling: grants.into_grants(), + }; + ExternalIssuerRegistry { + namespace_id, + generation: 1, + issuers: vec![entry], + } + .validate() + .map_err(|_| { + ApiBadRequest { + reason: "invalid issuer registration".into(), + } + .build() + })?; + let response = mutate( + &ctx, + MutationRequest { + namespace_id, + name: path.issuer_name, + replacement: Some(body), + }, + ) + .await?; + Ok(UpsertResponse { + issuer: response.issuer.context("missing registered issuer")?, + }) +} + +#[utoipa::path( + delete, operation_id = "auth_issuers_delete", path = "/namespaces/{namespace}/auth/issuers/{issuer_name}", + params(("namespace" = String, Path, description = "Namespace name"), ("issuer_name" = String, Path, description = "Issuer registration name")), + responses((status = 200, body = DeleteResponse)), security(("bearer_auth" = [])), +)] +#[tracing::instrument(skip_all)] +pub async fn delete(Extension(ctx): Extension, Path(path): Path) -> Response { + match delete_inner(ctx, path).await { + Ok(result) => Json(result).into_response(), + Err(error) => ApiError::from(error).into_response(), + } +} + +async fn delete_inner(ctx: ApiCtx, path: IssuerPath) -> Result { + let namespace_id = resolve_namespace(&ctx, path.namespace).await?; + // Like registration, removal must not rely on a stale ACL grant or a JWT. + let credential = ctx.authenticate(Consistency::Authoritative).await?; + credential.require_acl()?.authorize(&AccessRequest { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Jwt, + target: Scope::Any, + operation: OperationKind::Delete, + })?; + mutate( + &ctx, + MutationRequest { + namespace_id, + name: path.issuer_name, + replacement: None, + }, + ) + .await?; + Ok(DeleteResponse {}) +} + +async fn mutate(ctx: &ApiCtx, request: MutationRequest) -> Result { + if ctx.config().is_leader() { + rivet_api_peer::auth::issuers::mutate(ctx.clone().into(), (), (), request).await + } else { + let leader_dc = ctx.config().leader_dc()?; + request_remote_datacenter::( + ctx.config(), + leader_dc.datacenter_label, + "/auth/issuers/mutate", + Method::POST, + Option::<&()>::None, + Some(&request), + ) + .await + } +} + +async fn resolve_namespace(ctx: &ApiCtx, name: String) -> Result { + Ok(ctx + .op(namespace::ops::resolve_for_name_global::Input { name }) + .await? + .ok_or_else(|| namespace::errors::Namespace::NotFound.build())? + .namespace_id) +} diff --git a/engine/packages/api-public/src/auth/jwks.rs b/engine/packages/api-public/src/auth/jwks.rs new file mode 100644 index 0000000000..f030e3891d --- /dev/null +++ b/engine/packages/api-public/src/auth/jwks.rs @@ -0,0 +1,52 @@ +use anyhow::Result; +use axum::{ + http::header::{CACHE_CONTROL, CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; +use rivet_api_builder::{ApiError, extract::Extension}; + +use crate::ctx::ApiCtx; + +/// Publishes only the public halves of Rivet's currently valid signing keys. +#[utoipa::path( + get, + operation_id = "auth_jwks_get", + path = "/.well-known/jwks.json", + responses((status = 200, description = "Public JSON Web Key Set", body = serde_json::Value)), + security(()) +)] +#[tracing::instrument(skip_all)] +pub async fn get(Extension(ctx): Extension) -> Response { + match get_inner(ctx).await { + Ok((json, max_age)) => ( + [ + (CONTENT_TYPE, "application/jwk-set+json".to_owned()), + (CACHE_CONTROL, format!("public, max-age={max_age}")), + ], + json, + ) + .into_response(), + Err(error) => ApiError::from(error).into_response(), + } +} + +async fn get_inner(ctx: ApiCtx) -> Result<(String, u64)> { + ctx.skip_auth(); + let snapshot = ctx + .jwt_key_ring_cache() + .map_err(|_| rivet_auth::errors::Auth::VerificationUnavailable.build())? + .public_snapshot() + .await + .map_err(rivet_auth_jwt::key_ring_cache::VerificationFailure::into_error)?; + let now_ms = rivet_util::timestamp::now(); + let jwks = rivet_auth_jwt::jwks::JwkSet::from_verification_keys(&snapshot.keys, now_ms); + let jwt = &ctx.config().auth_required()?.jwt; + // External verifiers learn the pending key before it becomes active. Keep public caches + // shorter than that publication lead, and bounded after an emergency key revocation. + let max_age = jwt + .verifier_cache_ttl() + .as_secs() + .min(jwt.key_publish_lead().as_secs().saturating_sub(1)) + .min(60); + Ok((serde_json::to_string(&jwks)?, max_age)) +} diff --git a/engine/packages/api-public/src/auth/mod.rs b/engine/packages/api-public/src/auth/mod.rs index 5c76635567..e88baf26fa 100644 --- a/engine/packages/api-public/src/auth/mod.rs +++ b/engine/packages/api-public/src/auth/mod.rs @@ -1 +1,11 @@ +pub mod application_tokens; +pub mod issuers; +pub mod jwks; +pub(crate) mod signing; +mod strict_json; pub mod tokens; +pub mod webhook_discovery; +pub mod webhook_keys; +pub mod webhook_signing; + +pub mod signing_audit; diff --git a/engine/packages/api-public/src/auth/signing.rs b/engine/packages/api-public/src/auth/signing.rs new file mode 100644 index 0000000000..7f92fcf4af --- /dev/null +++ b/engine/packages/api-public/src/auth/signing.rs @@ -0,0 +1,160 @@ +use crate::{ctx::ApiCtx, errors::Signing}; +use anyhow::Result; +use rivet_auth::{Consistency, ResourceKind}; +use rivet_util::Id; + +pub async fn credential( + ctx: &ApiCtx, + resource: ResourceKind, +) -> Result<(Id, rivet_auth::AclCredential)> { + if !ctx.config().auth_required()?.jwt.issuance_enabled() + || ctx.config().protocols().acl_rule.version() < 3 + { + ctx.skip_auth(); + return Err(Signing::Unavailable.build()); + } + let authenticated = ctx.authenticate(Consistency::Authoritative).await?; + let acl = authenticated.require_acl()?; + ctx.signing_audit.caller(acl.token_id()); + let namespace_id = acl.signing_namespace(resource)?; + namespace_exists(ctx, namespace_id).await?; + if acl + .expires_ts() + .is_some_and(|exp| exp <= rivet_util::timestamp::now()) + { + return Err(rivet_auth::errors::Auth::TokenExpired.build()); + } + ctx.signing_audit.identity(acl.token_id(), namespace_id); + Ok((namespace_id, acl.clone())) +} + +pub async fn namespace_exists(ctx: &ApiCtx, namespace_id: Id) -> Result<()> { + let exists = if ctx.config().is_leader() { + ctx.op(namespace::ops::exists_authoritative::Input { namespace_id }) + .await? + } else { + rivet_api_util::request_remote_datacenter::( + ctx.config(), + ctx.config().leader_dc()?.datacenter_label, + &format!("/namespaces/{namespace_id}/exists-authoritative"), + rivet_api_util::Method::GET, + Option::<&()>::None, + Option::<&()>::None, + ) + .await? + }; + if !exists { + return Err(rivet_auth::errors::Auth::InsufficientPermissions.build()); + } + Ok(()) +} + +pub fn no_store(mut response: axum::response::Response) -> axum::response::Response { + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-store"), + ); + response +} + +pub fn expiration(now: i64, seconds: u64, parent: Option) -> Result { + let exp = now + .checked_add( + i64::try_from(seconds) + .ok() + .and_then(|s| s.checked_mul(1000)) + .ok_or_else(|| Signing::InvalidInput.build())?, + ) + .ok_or_else(|| Signing::InvalidInput.build())?; + let exp = parent.map_or(exp, |p| exp.min(p)); + if exp / 1000 <= now / 1000 { + return Err(rivet_auth::errors::Auth::TokenExpired.build()); + } + Ok(exp) +} + +/// The authority owns a single consensus budget; a transport failure never falls back locally. +pub async fn charge( + ctx: &ApiCtx, + namespace_id: Id, + charge: rivet_auth_jwt::ops::signing_quota::Charge, +) -> Result<()> { + let input = rivet_auth_jwt::ops::signing_quota::Input { + namespace_id, + charge, + }; + let result = if ctx.config().is_leader() { + rivet_api_peer::auth::signing_quota::charge(ctx.clone().into(), (), (), input).await + } else { + rivet_api_util::request_remote_datacenter( + ctx.config(), + ctx.config().leader_dc()?.datacenter_label, + "/auth/signing-quota", + rivet_api_util::Method::POST, + Option::<&()>::None, + Some(&input), + ) + .await + }; + result.map_err(normalize_error) +} + +pub fn error_response(error: anyhow::Error) -> axum::response::Response { + use axum::response::IntoResponse; + rivet_api_builder::ApiError::from(normalize_error(error)).into_response() +} + +pub fn normalize_error(error: anyhow::Error) -> anyhow::Error { + if error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .is_some_and(|e| matches!(e.group(), "auth" | "signing")) + { + return error; + } + if let Some(raw) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + return match (raw.1.group.as_ref(), raw.1.code.as_ref()) { + ("signing", "invalid_input") => Signing::InvalidInput.build(), + ("signing", "payload_too_large") => Signing::PayloadTooLarge.build(), + ("signing", "throttled") => Signing::Throttled.build(), + ("signing", "conflict") => Signing::Conflict.build(), + ("signing", "not_found") => Signing::NotFound.build(), + ("auth", "invalid_token") => rivet_auth::errors::Auth::InvalidToken.build(), + ("auth", "token_expired") => rivet_auth::errors::Auth::TokenExpired.build(), + ("auth", "insufficient_permissions") => { + rivet_auth::errors::Auth::InsufficientPermissions.build() + } + _ => Signing::Unavailable.build(), + }; + } + Signing::Unavailable.build() +} + +pub async fn management_body( + request: axum::extract::Request, +) -> Result { + if request + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.split(';').next()) + .map(str::trim) + != Some("application/json") + || request + .headers() + .contains_key(axum::http::header::CONTENT_ENCODING) + { + return Err(Signing::InvalidInput.build()); + } + let bytes = tokio::time::timeout( + std::time::Duration::from_secs(30), + axum::body::to_bytes(request.into_body(), 4096), + ) + .await + .map_err(|_| Signing::Unavailable.build())? + .map_err(|_| Signing::PayloadTooLarge.build())?; + super::strict_json::parse(&bytes) +} diff --git a/engine/packages/api-public/src/auth/signing_audit.rs b/engine/packages/api-public/src/auth/signing_audit.rs new file mode 100644 index 0000000000..4f790bb271 --- /dev/null +++ b/engine/packages/api-public/src/auth/signing_audit.rs @@ -0,0 +1,121 @@ +//! Safe, bounded observability fields. Never retain a credential or signed input. +use axum::response::Response; +use rivet_util::Id; +use std::{ + sync::{Arc, Mutex}, + time::Instant, +}; + +#[derive(Clone, Default)] +pub struct Audit(Arc>); +#[derive(Default)] +struct Fields { + caller: Option, + namespace: Option, + kid: Option, + generation: Option, + bytes: u64, + purpose: Option, +} +impl Audit { + pub fn purpose(&self, purpose: &str) { + self.0.lock().unwrap().purpose = Some(purpose.to_owned()); + } + pub fn caller(&self, caller: Id) { + self.0.lock().unwrap().caller = Some(caller); + } + pub fn identity(&self, caller: Id, namespace: Id) { + let mut f = self.0.lock().unwrap(); + f.caller = Some(caller); + f.namespace = Some(namespace); + } + pub fn key(&self, kid: String, generation: u64) { + let mut f = self.0.lock().unwrap(); + f.kid = Some(kid); + f.generation = Some(generation); + } + pub fn bytes(&self, bytes: u64) { + self.0.lock().unwrap().bytes = bytes; + } +} +pub struct Guard { + audit: Audit, + operation: &'static str, + started: Instant, + outcome: &'static str, + error_code: Option, +} +impl Guard { + pub fn start(audit: Audit, path: &str) -> Option { + let operation = match path { + "/auth/application-tokens" => "issue_token", + "/auth/webhooks/sign" => "sign_webhook", + "/auth/webhooks/ensure" => "ensure_webhook", + _ if path.ends_with("/auth/webhook-keys/rotate") => "rotate_webhook", + _ if path.ends_with("/auth/webhook-keys/revoke") => "revoke_webhook", + _ if path.ends_with("/auth/webhook-keys") => "status_webhook", + _ if path.ends_with("/auth/webhooks/jwks.json") => "discover_webhook", + _ if path.ends_with("/auth/webhooks") => "metadata_webhook", + _ => return None, + }; + Some(Self { + audit, + operation, + started: Instant::now(), + outcome: "cancelled", + error_code: None, + }) + } + pub fn response(&mut self, response: &Response) { + self.error_code = response + .extensions() + .get::() + .map(|e| format!("{}.{}", e.group, e.code)); + self.outcome = match response.status().as_u16() { + 200..=299 => "success", + 400 | 415 | 422 => "invalid_input", + 401 => "invalid_token", + 403 => "denied", + 404 => "not_found", + 409 => "conflict", + 413 => "too_large", + 429 => "throttled", + _ => "unavailable", + }; + } +} +impl Drop for Guard { + fn drop(&mut self) { + let f = self.audit.0.lock().unwrap(); + let purpose = if self.operation == "issue_token" { + "application" + } else { + "webhook" + }; + let elapsed = self.started.elapsed().as_secs_f64(); + tracing::info!(target: "rivet_signing_audit", operation = self.operation, purpose = f.purpose.as_deref().unwrap_or(purpose), purpose_class = purpose, outcome = self.outcome, error_code = ?self.error_code, + caller = ?f.caller, namespace = ?f.namespace, kid = ?f.kid, generation = ?f.generation, + bytes = f.bytes, latency_seconds = elapsed, "signing request completed"); + rivet_auth_jwt::metrics::SIGNING_REQUESTS + .with_label_values(&[self.operation, self.outcome]) + .inc(); + rivet_auth_jwt::metrics::SIGNING_LATENCY + .with_label_values(&[self.operation, self.outcome]) + .observe(elapsed); + rivet_auth_jwt::metrics::SIGNING_BYTES + .with_label_values(&[self.operation]) + .inc_by(f.bytes); + } +} +static REQUESTS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(64))); +pub fn small_body_permit() -> anyhow::Result { + REQUESTS + .clone() + .try_acquire_owned() + .map_err(|_| crate::errors::Signing::Throttled.build()) +} + +#[cfg(test)] +#[path = "../../tests/unit/signing_audit.rs"] +mod tests; diff --git a/engine/packages/api-public/src/auth/strict_json.rs b/engine/packages/api-public/src/auth/strict_json.rs new file mode 100644 index 0000000000..519b4f4fba --- /dev/null +++ b/engine/packages/api-public/src/auth/strict_json.rs @@ -0,0 +1,70 @@ +//! Reject duplicate JSON members before converting to a typed request. Never return input text. +use serde::{ + Deserialize, Deserializer, + de::{self, MapAccess, SeqAccess, Visitor}, +}; +use serde_json::{Map, Value}; +use std::fmt; + +struct Unique(Value); +impl<'de> Deserialize<'de> for Unique { + fn deserialize>(d: D) -> Result { + struct UniqueVisitor; + impl<'de> Visitor<'de> for UniqueVisitor { + type Value = Unique; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("JSON without duplicate fields") + } + fn visit_map>(self, mut m: M) -> Result { + let mut values = Map::new(); + while let Some((key, Unique(value))) = m.next_entry::()? { + if values.insert(key, value).is_some() { + return Err(de::Error::custom("duplicate JSON member")); + } + } + Ok(Unique(Value::Object(values))) + } + fn visit_seq>(self, mut s: S) -> Result { + let mut values = Vec::new(); + while let Some(Unique(value)) = s.next_element()? { + values.push(value); + } + Ok(Unique(Value::Array(values))) + } + fn visit_bool(self, v: bool) -> Result { + Ok(Unique(v.into())) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Unique(v.into())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Unique(v.into())) + } + fn visit_f64(self, v: f64) -> Result { + Ok(Unique(Value::Number( + serde_json::Number::from_f64(v).ok_or_else(|| E::custom("invalid number"))?, + ))) + } + fn visit_str(self, v: &str) -> Result { + Ok(Unique(v.into())) + } + fn visit_string(self, v: String) -> Result { + Ok(Unique(v.into())) + } + fn visit_unit(self) -> Result { + Ok(Unique(Value::Null)) + } + } + d.deserialize_any(UniqueVisitor) + } +} + +pub fn parse(bytes: &[u8]) -> anyhow::Result { + let unique: Unique = + serde_json::from_slice(bytes).map_err(|_| crate::errors::Signing::InvalidInput.build())?; + serde_json::from_value(unique.0).map_err(|_| crate::errors::Signing::InvalidInput.build()) +} + +#[cfg(test)] +#[path = "../../tests/unit/signing_json.rs"] +mod tests; diff --git a/engine/packages/api-public/src/auth/webhook_discovery.rs b/engine/packages/api-public/src/auth/webhook_discovery.rs new file mode 100644 index 0000000000..d7df6b6a7b --- /dev/null +++ b/engine/packages/api-public/src/auth/webhook_discovery.rs @@ -0,0 +1,81 @@ +use super::signing; +use crate::{ctx::ApiCtx, errors::Signing}; +use anyhow::Result; +use axum::{ + http::{HeaderValue, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; +use rivet_api_builder::extract::{Extension, Json, Path}; +use rivet_api_peer::auth::webhooks::Discovery; +use rivet_api_types::auth::webhooks::{Jwks, Metadata}; +use rivet_util::Id; + +#[utoipa::path(get, operation_id = "auth_webhooks_jwks", path = "/namespaces/{namespace}/auth/webhooks/jwks.json", + params(("namespace" = String, Path, description = "Immutable namespace ID")), + responses((status = 200, body = Jwks, content_type = "application/jwk-set+json")), security())] +#[tracing::instrument(skip_all)] +pub async fn jwks( + Extension(ctx): Extension, + Path((namespace,)): Path<(String,)>, +) -> Response { + ctx.skip_auth(); + signing::no_store(match parse_and_get(&ctx, &namespace).await { + Ok(discovery) => { + let mut response = Json(discovery.jwks).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("application/jwk-set+json"), + ); + response + } + Err(error) => signing::error_response(error), + }) +} + +#[utoipa::path(get, operation_id = "auth_webhooks_metadata", path = "/namespaces/{namespace}/auth/webhooks", + params(("namespace" = String, Path, description = "Immutable namespace ID")), + responses((status = 200, body = Metadata)), security())] +#[tracing::instrument(skip_all)] +pub async fn metadata( + Extension(ctx): Extension, + Path((namespace,)): Path<(String,)>, +) -> Response { + ctx.skip_auth(); + signing::no_store( + match parse_and_get(&ctx, &namespace) + .await + .and_then(|d| d.metadata.ok_or_else(|| Signing::Unavailable.build())) + { + Ok(metadata) => Json(metadata).into_response(), + Err(error) => signing::error_response(error), + }, + ) +} + +async fn parse_and_get(ctx: &ApiCtx, namespace: &str) -> Result { + let id = namespace + .parse::() + .map_err(|_| Signing::NotFound.build())?; + get(ctx, id).await +} + +pub(crate) async fn get(ctx: &ApiCtx, id: Id) -> Result { + let started = std::time::Instant::now(); + let response = if ctx.config().is_leader() { + rivet_api_peer::auth::webhooks::discovery(ctx.clone().into(), (id,), ()).await? + } else { + rivet_api_util::request_remote_datacenter( + ctx.config(), + ctx.config().leader_dc()?.datacenter_label, + &format!("/auth/webhooks/{id}/discovery"), + rivet_api_util::Method::GET, + Option::<&()>::None, + Option::<&()>::None, + ) + .await? + }; + if started.elapsed() >= rivet_auth_jwt::SIGNING_CACHE_LEASE { + return Err(Signing::Unavailable.build()); + } + Ok(response) +} diff --git a/engine/packages/api-public/src/auth/webhook_keys.rs b/engine/packages/api-public/src/auth/webhook_keys.rs new file mode 100644 index 0000000000..a9c9dcd1ce --- /dev/null +++ b/engine/packages/api-public/src/auth/webhook_keys.rs @@ -0,0 +1,210 @@ +use super::signing; +use crate::{ctx::ApiCtx, errors::Signing}; +use anyhow::Result; +use axum::response::{IntoResponse, Response}; +use rivet_api_builder::extract::{Extension, Json, Path}; +use rivet_api_peer::auth::webhooks::MutationRequest; +use rivet_api_types::auth::webhooks::{KeyStatus, Metadata, RevokeRequest, RotateRequest}; +use rivet_auth::{AccessRequest, Consistency, OperationKind, ResourceKind, Scope}; +use rivet_auth_jwt::ops::webhook::Change; +use rivet_util::Id; + +#[utoipa::path(post, operation_id = "auth_webhooks_ensure", path = "/auth/webhooks/ensure", + responses((status = 200, body = Metadata)), security(("bearer_auth" = [])))] +#[tracing::instrument(skip_all)] +pub async fn ensure(Extension(ctx): Extension) -> Response { + let result: Result = async { + let (namespace_id, _) = signing::credential(&ctx, ResourceKind::WebhookSigning).await?; + signing::charge( + &ctx, + namespace_id, + rivet_auth_jwt::ops::signing_quota::Charge::Webhook, + ) + .await?; + mutate(&ctx, namespace_id, Change::Ensure).await?; + super::webhook_discovery::get(&ctx, namespace_id) + .await? + .metadata + .ok_or_else(|| Signing::Unavailable.build()) + } + .await; + signing::no_store(match result { + Ok(body) => Json(body).into_response(), + Err(error) => signing::error_response(error), + }) +} + +#[utoipa::path(get, operation_id = "auth_webhook_keys_get", path = "/namespaces/{namespace}/auth/webhook-keys", + params(("namespace" = String, Path, description = "Namespace name")), + responses((status = 200, body = KeyStatus)), security(("bearer_auth" = [])))] +#[tracing::instrument(skip_all)] +pub async fn get(Extension(ctx): Extension, Path((name,)): Path<(String,)>) -> Response { + respond( + async { + let (id, _) = authorize(&ctx, name, OperationKind::Read).await?; + if ctx.config().is_leader() { + rivet_api_peer::auth::webhooks::get(ctx.into(), (id,), ()).await + } else { + rivet_api_util::request_remote_datacenter( + ctx.config(), + ctx.config().leader_dc()?.datacenter_label, + &format!("/auth/webhooks/{id}/status"), + rivet_api_util::Method::GET, + Option::<&()>::None, + Option::<&()>::None, + ) + .await + } + } + .await, + ) +} + +#[utoipa::path(post, operation_id = "auth_webhook_keys_rotate", path = "/namespaces/{namespace}/auth/webhook-keys/rotate", + params(("namespace" = String, Path, description = "Namespace name")), request_body = RotateRequest, + responses((status = 200, body = KeyStatus)), security(("bearer_auth" = [])))] +#[tracing::instrument(skip_all)] +pub async fn rotate( + Extension(ctx): Extension, + Path((name,)): Path<(String,)>, + request: axum::extract::Request, +) -> Response { + respond( + async { + let (id, _) = authorize(&ctx, name.clone(), OperationKind::Update).await?; + let body: RotateRequest = signing::management_body(request).await?; + let (current_id, deadline) = authorize(&ctx, name, OperationKind::Update).await?; + if current_id != id { + return Err(rivet_auth::errors::Auth::InsufficientPermissions.build()); + } + mutate_admitted( + &ctx, + id, + Change::Rotate { + expected_generation: body.expected_generation, + }, + Some(deadline), + ) + .await + } + .await, + ) +} + +#[utoipa::path(post, operation_id = "auth_webhook_keys_revoke", path = "/namespaces/{namespace}/auth/webhook-keys/revoke", + params(("namespace" = String, Path, description = "Namespace name")), request_body = RevokeRequest, + responses((status = 200, body = KeyStatus)), security(("bearer_auth" = [])))] +#[tracing::instrument(skip_all)] +pub async fn revoke( + Extension(ctx): Extension, + Path((name,)): Path<(String,)>, + request: axum::extract::Request, +) -> Response { + respond( + async { + let (id, _) = authorize(&ctx, name.clone(), OperationKind::Delete).await?; + let body: RevokeRequest = signing::management_body(request).await?; + let (current_id, deadline) = authorize(&ctx, name, OperationKind::Delete).await?; + if current_id != id { + return Err(rivet_auth::errors::Auth::InsufficientPermissions.build()); + } + let request_id = gas::prelude::Uuid::parse_str(&body.request_id) + .map_err(|_| Signing::InvalidInput.build())? + .into_bytes(); + mutate_admitted( + &ctx, + id, + Change::Revoke { + request_id, + expected_generation: body.expected_generation, + kids: body.kids, + }, + Some(deadline), + ) + .await + } + .await, + ) +} + +async fn authorize(ctx: &ApiCtx, name: String, operation: OperationKind) -> Result<(Id, i64)> { + let started = rivet_util::timestamp::now(); + let credential = ctx.authenticate(Consistency::Authoritative).await?; + let acl = credential.require_acl()?; + ctx.signing_audit.caller(acl.token_id()); + let ns = ctx + .op(namespace::ops::resolve_for_name_global::Input { name }) + .await? + .ok_or_else(|| Signing::NotFound.build())?; + acl.authorize(&AccessRequest { + namespace: Scope::Id(ns.namespace_id), + resource: ResourceKind::WebhookSigning, + target: Scope::Any, + operation, + })?; + signing::namespace_exists(ctx, ns.namespace_id).await?; + if acl + .expires_ts() + .is_some_and(|exp| exp <= rivet_util::timestamp::now()) + { + return Err(rivet_auth::errors::Auth::TokenExpired.build()); + } + let deadline = admission_deadline(started, acl.expires_ts()); + rivet_auth_jwt::ops::webhook::mutate::check_admission( + Some(deadline), + rivet_util::timestamp::now(), + ) + .map_err(|_| Signing::Unavailable.build())?; + ctx.signing_audit.identity(acl.token_id(), ns.namespace_id); + Ok((ns.namespace_id, deadline)) +} + +fn admission_deadline(started: i64, expires_ts: Option) -> i64 { + expires_ts + .unwrap_or(i64::MAX) + .min(started.saturating_add(5_000)) +} + +pub(crate) async fn mutate(ctx: &ApiCtx, namespace_id: Id, change: Change) -> Result { + mutate_admitted(ctx, namespace_id, change, None).await +} + +async fn mutate_admitted( + ctx: &ApiCtx, + namespace_id: Id, + change: Change, + admission_deadline_ts: Option, +) -> Result { + let request = MutationRequest { + namespace_id, + change, + admission_deadline_ts, + }; + let status = if ctx.config().is_leader() { + rivet_api_peer::auth::webhooks::mutate(ctx.clone().into(), (), (), request).await + } else { + rivet_api_util::request_remote_datacenter( + ctx.config(), + ctx.config().leader_dc()?.datacenter_label, + "/auth/webhooks/mutate", + rivet_api_util::Method::POST, + Option::<&()>::None, + Some(&request), + ) + .await + }?; + ctx.signing_audit + .key(status.active_kid.clone(), status.generation); + Ok(status) +} + +fn respond(result: Result) -> Response { + signing::no_store(match result { + Ok(body) => Json(body).into_response(), + Err(error) => signing::error_response(error), + }) +} + +#[cfg(test)] +#[path = "../../tests/unit/webhook_management.rs"] +mod tests; diff --git a/engine/packages/api-public/src/auth/webhook_signing.rs b/engine/packages/api-public/src/auth/webhook_signing.rs new file mode 100644 index 0000000000..00e846fe4a --- /dev/null +++ b/engine/packages/api-public/src/auth/webhook_signing.rs @@ -0,0 +1,92 @@ +use super::{signing, webhook_keys}; +use crate::{ctx::ApiCtx, errors::Signing}; +use anyhow::Result; +use axum::{ + extract::Request, + response::{IntoResponse, Response}, +}; +use rivet_api_builder::extract::{Extension, Json}; +use rivet_api_peer::auth::webhook_signing::{self as peer, DEADLINE_HEADER}; +use rivet_api_types::auth::webhooks::{SignResponse, WebhookPayload}; +use rivet_auth::ResourceKind; + +#[utoipa::path(post, operation_id = "auth_webhooks_sign", path = "/auth/webhooks/sign", + request_body(content = inline(WebhookPayload), content_type = "application/octet-stream"), + responses((status = 200, body = SignResponse)), security(("bearer_auth" = [])))] +#[tracing::instrument(skip_all)] +pub async fn sign(Extension(ctx): Extension, request: Request) -> Response { + signing::no_store(match sign_inner(&ctx, request).await { + Ok(body) => Json(body).into_response(), + Err(error) => signing::error_response(error), + }) +} + +async fn sign_inner(ctx: &ApiCtx, request: Request) -> Result { + let (namespace_id, _) = signing::credential(ctx, ResourceKind::WebhookSigning).await?; + signing::charge( + ctx, + namespace_id, + rivet_auth_jwt::ops::signing_quota::Charge::Webhook, + ) + .await?; + // Provisions lazily and repairs workflow dispatch after an interrupted ensure. + webhook_keys::mutate( + ctx, + namespace_id, + rivet_auth_jwt::ops::webhook::Change::Ensure, + ) + .await?; + let cap = ctx.config().auth_required()?.jwt.signing.payload_bytes as usize; + let (body, permit) = peer::read_body_observed( + request, + cap, + |bytes| { + signing::charge( + ctx, + namespace_id, + rivet_auth_jwt::ops::signing_quota::Charge::Bytes(bytes), + ) + }, + |bytes| ctx.signing_audit.bytes(bytes), + ) + .await?; + ctx.signing_audit.bytes(body.len() as u64); + let check_started = rivet_util::timestamp::now(); + let (current_namespace, acl) = signing::credential(ctx, ResourceKind::WebhookSigning).await?; + if current_namespace != namespace_id { + return Err(rivet_auth::errors::Auth::InsufficientPermissions.build()); + } + let deadline = acl + .expires_ts() + .unwrap_or(i64::MAX) + .min(check_started.saturating_add(5_000)); + if ctx.config().is_leader() { + let signed = peer::sign_body(ctx, namespace_id, deadline, body, permit).await?; + ctx.signing_audit + .key(signed.response.kid.clone(), signed.generation); + return Ok(signed.response); + } + let _permit = permit; + let url = ctx + .config() + .leader_dc()? + .peer_url + .join(&format!("/auth/webhooks/{namespace_id}/sign"))?; + let response = rivet_pools::reqwest::client() + .await? + .post(url) + .header("content-type", "application/octet-stream") + .header(DEADLINE_HEADER, deadline.to_string()) + .timeout(std::time::Duration::from_secs(5)) + .body(body) + .send() + .await + .map_err(|_| Signing::Unavailable.build())?; + let result: peer::Signed = rivet_api_util::parse_response(response).await?; + if rivet_util::timestamp::now() >= deadline { + return Err(Signing::Unavailable.build()); + } + ctx.signing_audit + .key(result.response.kid.clone(), result.generation); + Ok(result.response) +} diff --git a/engine/packages/api-public/src/ctx.rs b/engine/packages/api-public/src/ctx.rs index d1d8686a3a..e47cfeb9f0 100644 --- a/engine/packages/api-public/src/ctx.rs +++ b/engine/packages/api-public/src/ctx.rs @@ -10,6 +10,7 @@ use std::{ #[derive(Clone)] pub struct ApiCtx { + pub(crate) signing_audit: crate::auth::signing_audit::Audit, inner: rivet_api_builder::ApiCtx, token: Option, jwt_key_ring_cache: Option>, @@ -24,6 +25,7 @@ impl ApiCtx { jwt_key_ring_cache: Option>, ) -> Self { Self { + signing_audit: Default::default(), inner, token, jwt_key_ring_cache, diff --git a/engine/packages/api-public/src/errors.rs b/engine/packages/api-public/src/errors.rs index 3a4bc5f7cc..31d3fae8b7 100644 --- a/engine/packages/api-public/src/errors.rs +++ b/engine/packages/api-public/src/errors.rs @@ -21,3 +21,5 @@ pub enum Validation { )] RaceCondition, } + +pub use rivet_auth::errors::Signing; diff --git a/engine/packages/api-public/src/router.rs b/engine/packages/api-public/src/router.rs index d53f3d0970..85fdd4c5a5 100644 --- a/engine/packages/api-public/src/router.rs +++ b/engine/packages/api-public/src/router.rs @@ -39,6 +39,19 @@ use crate::{ health::fanout, metadata::get, auth::tokens::create, + auth::application_tokens::create, + auth::webhook_keys::ensure, + auth::webhook_signing::sign, + auth::webhook_discovery::jwks, + auth::webhook_discovery::metadata, + auth::webhook_keys::get, + auth::webhook_keys::rotate, + auth::webhook_keys::revoke, + auth::jwks::get, + auth::issuers::list, + auth::issuers::get, + auth::issuers::upsert, + auth::issuers::delete, auth::tokens::inspect, ), components( @@ -70,6 +83,59 @@ pub async fn router( ) // MARK: Metadata .route("/metadata", axum::routing::get(metadata::get)) + .route( + "/.well-known/jwks.json", + axum::routing::get(auth::jwks::get), + ) + .route( + "/namespaces/{namespace}/auth/issuers", + axum::routing::get(auth::issuers::list), + ) + .route( + "/namespaces/{namespace}/auth/issuers/{issuer_name}", + axum::routing::get(auth::issuers::get) + .put(auth::issuers::upsert) + .delete(auth::issuers::delete) + .layer(axum::extract::DefaultBodyLimit::max(32 * 1024)), + ) + // MARK: Webhook discovery + .route( + "/namespaces/{namespace}/auth/webhooks/jwks.json", + axum::routing::get(auth::webhook_discovery::jwks), + ) + .route( + "/namespaces/{namespace}/auth/webhooks", + axum::routing::get(auth::webhook_discovery::metadata), + ) + // MARK: Webhook signing + .route( + "/auth/webhooks/sign", + axum::routing::post(auth::webhook_signing::sign), + ) + // MARK: Webhook keys + .route( + "/auth/webhooks/ensure", + axum::routing::post(auth::webhook_keys::ensure), + ) + .route( + "/namespaces/{namespace}/auth/webhook-keys", + axum::routing::get(auth::webhook_keys::get), + ) + .route( + "/namespaces/{namespace}/auth/webhook-keys/rotate", + axum::routing::post(auth::webhook_keys::rotate) + .layer(axum::extract::DefaultBodyLimit::max(4096)), + ) + .route( + "/namespaces/{namespace}/auth/webhook-keys/revoke", + axum::routing::post(auth::webhook_keys::revoke) + .layer(axum::extract::DefaultBodyLimit::max(4096)), + ) + // MARK: Application signing + .route( + "/auth/application-tokens", + axum::routing::post(auth::application_tokens::create), + ) // MARK: Namespaces .route("/namespaces", axum::routing::get(namespaces::list)) .route("/namespaces", axum::routing::post(namespaces::create)) @@ -185,8 +251,27 @@ async fn auth_middleware( let method = req.method().clone(); let path = req.uri().path().to_string(); - // Run endpoint - let res = next.run(req).await; + let mut audit = auth::signing_audit::Guard::start(ctx.signing_audit.clone(), &path); + let _admission = if audit.is_some() { + match auth::signing_audit::small_body_permit() { + Ok(permit) => Some(permit), + Err(error) => { + let response = auth::signing::no_store( + rivet_api_builder::ApiError::from(error).into_response(), + ); + audit.as_mut().unwrap().response(&response); + return Ok(response); + } + } + } else { + None + }; + // Includes extractor failures; the guard also records cancellation when this future drops. + let mut res = next.run(req).await; + if let Some(audit) = audit.as_mut() { + audit.response(&res); + res = auth::signing::no_store(res); + } // Verify auth was handled if res.extensions().get::().is_none() diff --git a/engine/packages/api-public/tests/router.rs b/engine/packages/api-public/tests/router.rs index a82488962e..ab135b6e00 100644 --- a/engine/packages/api-public/tests/router.rs +++ b/engine/packages/api-public/tests/router.rs @@ -7,6 +7,24 @@ fn jwt_token_routes_are_root_scoped() { let openapi = rivet_api_public::router::ApiDoc::openapi(); assert!(openapi.paths.paths.contains_key("/auth/tokens")); assert!(openapi.paths.paths.contains_key("/auth/tokens/inspect")); + assert!(openapi.paths.paths.contains_key("/.well-known/jwks.json")); + assert_eq!( + serde_json::to_value(&openapi).unwrap()["paths"]["/.well-known/jwks.json"]["get"]["security"], + serde_json::json!([{}]), + "published verification keys must not require bearer authentication", + ); + assert!( + openapi + .paths + .paths + .contains_key("/namespaces/{namespace}/auth/issuers") + ); + assert!( + openapi + .paths + .paths + .contains_key("/namespaces/{namespace}/auth/issuers/{issuer_name}") + ); assert!( !openapi .paths @@ -40,4 +58,26 @@ async fn router_preserves_method_handling_and_public_health() { .await .assert_status(StatusCode::METHOD_NOT_ALLOWED); server.get("/health").await.assert_status_ok(); + server + .get("/.well-known/jwks.json") + .await + .assert_status(StatusCode::SERVICE_UNAVAILABLE); +} + +#[test] +fn signing_contract_is_binary_and_public_discovery_is_unauthenticated() { + let doc = serde_json::to_value(rivet_api_public::router::ApiDoc::openapi()).unwrap(); + let body = &doc["paths"]["/auth/webhooks/sign"]["post"]["requestBody"]["content"]["application/octet-stream"] + ["schema"]; + assert_eq!(body["type"], "string"); + assert_eq!(body["format"], "binary"); + for path in [ + "/namespaces/{namespace}/auth/webhooks/jwks.json", + "/namespaces/{namespace}/auth/webhooks", + ] { + assert_eq!( + doc["paths"][path]["get"]["security"], + serde_json::json!([]) + ); + } } diff --git a/engine/packages/api-public/tests/unit/signing_audit.rs b/engine/packages/api-public/tests/unit/signing_audit.rs new file mode 100644 index 0000000000..a478677ca3 --- /dev/null +++ b/engine/packages/api-public/tests/unit/signing_audit.rs @@ -0,0 +1,137 @@ +use super::*; +use axum::{body::Body, http::Request, response::IntoResponse}; +use tracing::instrument::WithSubscriber; +use tracing::{ + Subscriber, + field::{Field, Visit}, + span::{Attributes, Id as SpanId, Record}, +}; + +#[derive(Clone, Default)] +struct Captured(Arc>); +impl Visit for Captured { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + use std::fmt::Write; + write!(self.0.lock().unwrap(), " {}={value:?}", field.name()).unwrap(); + } +} +impl Subscriber for Captured { + fn enabled(&self, _: &tracing::Metadata<'_>) -> bool { + true + } + fn new_span(&self, attrs: &Attributes<'_>) -> SpanId { + attrs.record(&mut self.clone()); + SpanId::from_u64(1) + } + fn record(&self, _: &SpanId, values: &Record<'_>) { + values.record(&mut self.clone()); + } + fn record_follows_from(&self, _: &SpanId, _: &SpanId) {} + fn event(&self, event: &tracing::Event<'_>) { + self.0.lock().unwrap().push_str(event.metadata().target()); + event.record(&mut self.clone()); + self.0.lock().unwrap().push('\n'); + } + fn enter(&self, _: &SpanId) {} + fn exit(&self, _: &SpanId) {} +} + +#[tokio::test] +async fn signing_audit_captures_safe_fields_without_payloads_tokens_or_remote_errors() { + let captured = Captured::default(); + let dispatch = tracing::Dispatch::new(captured.clone()); + tokio::spawn( + async move { + let audit = Audit::default(); + audit.identity(Id::new_v1(1), Id::new_v1(1)); + audit.purpose("durable-streams.callback"); + audit.key("ds_public-key".into(), 7); + let request = Request::builder() + .header("content-type", "application/octet-stream") + .header("authorization", "Bearer BEARER_SECRET_SENTINEL") + .header("webhook-signature", "HEADER_SIGNATURE_SECRET_SENTINEL") + .body(Body::from("BODY_SECRET_SENTINEL")) + .unwrap(); + let mut guard = Guard::start(audit.clone(), "/auth/webhooks/sign").unwrap(); + let (body, permit) = rivet_api_peer::auth::webhook_signing::read_body_observed( + request, + 1024, + |_| async { Ok(()) }, + |bytes| audit.bytes(bytes), + ) + .await + .unwrap(); + assert_eq!(body, b"BODY_SECRET_SENTINEL"); + drop(permit); + let response = axum::Json(serde_json::json!({"signature":"SIGNATURE_SECRET_SENTINEL"})) + .into_response(); + guard.response(&response); + drop(guard); + + let claims: serde_json::Value = crate::auth::strict_json::parse( + br#"{"claims":{"ds":{"data":"CLAIMS_SECRET_SENTINEL"}}}"#, + ) + .unwrap(); + assert!(claims["claims"].is_object()); + let mut guard = Guard::start(audit.clone(), "/auth/application-tokens").unwrap(); + guard.response( + &axum::Json(serde_json::json!({"token":"JWT_SECRET_SENTINEL"})).into_response(), + ); + drop(guard); + + let malformed = crate::auth::strict_json::parse::( + br#"{"claims":"MALFORMED_SECRET_SENTINEL","claims":1}"#, + ) + .unwrap_err(); + for error in [ + malformed, + crate::errors::Signing::PayloadTooLarge.build(), + crate::errors::Signing::Unavailable.build(), + anyhow::anyhow!("storage error: STORAGE_SECRET_SENTINEL"), + anyhow::anyhow!("signing error: SEED_SECRET_SENTINEL"), + anyhow::Error::new(rivet_api_builder::RawErrorResponse( + axum::http::StatusCode::TOO_MANY_REQUESTS, + rivet_api_builder::ErrorResponse { + group: "signing".into(), + code: "throttled".into(), + message: "REMOTE_SECRET_SENTINEL".into(), + metadata: Some( + serde_json::json!({"secret":"REMOTE_METADATA_SECRET_SENTINEL"}), + ), + }, + )), + ] { + let mut guard = Guard::start(audit.clone(), "/auth/webhooks/sign").unwrap(); + guard.response(&crate::auth::signing::error_response(error)); + } + // Dropping an unfinished request reports cancellation, using only admitted fields. + drop(Guard::start(audit, "/auth/webhooks/sign").unwrap()); + } + .with_subscriber(dispatch), + ) + .await + .unwrap(); + let output = captured.0.lock().unwrap().clone(); + assert!( + !output.contains("SECRET_SENTINEL"), + "secret appeared in captured tracing" + ); + for required in [ + "rivet_signing_audit", + "durable-streams.callback", + "ds_public-key", + "generation=Some(7)", + "bytes=20", + "signing.invalid_input", + "signing.payload_too_large", + "signing.unavailable", + "signing.throttled", + "cancelled", + "success", + ] { + assert!( + output.contains(required), + "missing safe audit field {required}: {output}" + ); + } +} diff --git a/engine/packages/api-public/tests/unit/signing_json.rs b/engine/packages/api-public/tests/unit/signing_json.rs new file mode 100644 index 0000000000..e1e28663fd --- /dev/null +++ b/engine/packages/api-public/tests/unit/signing_json.rs @@ -0,0 +1,18 @@ +use super::*; +#[test] +fn duplicate_and_unknown_fields_are_rejected_without_echoing_payloads() { + for bytes in [ + br#"{"audience":"one","audience":"SECRET_SENTINEL"}"#.as_slice(), + br#"{"claims":{"ds":{"key":1,"key":"SECRET_SENTINEL"}}}"#, + ] { + let error = parse::(bytes).unwrap_err(); + assert!(!format!("{error:?}").contains("SECRET_SENTINEL")); + } + assert!(parse::(br#"{"claims":{"ds":{"key":1}}}"#).is_ok()); + assert!( + parse::( + br#"{"audience":"ds","purpose":"callback","claims":{},"iss":"injected"}"# + ) + .is_err() + ); +} diff --git a/engine/packages/api-public/tests/unit/webhook_management.rs b/engine/packages/api-public/tests/unit/webhook_management.rs new file mode 100644 index 0000000000..4feb1b631c --- /dev/null +++ b/engine/packages/api-public/tests/unit/webhook_management.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn management_admission_is_bounded_by_expiry_and_time_since_authentication_started() { + assert_eq!(admission_deadline(10_000, None), 15_000); + assert_eq!(admission_deadline(10_000, Some(12_000)), 12_000); + assert_eq!(admission_deadline(10_000, Some(25_000)), 15_000); + // The forwarded request retains the same deadline even if it arrives much later. + let request = MutationRequest { + namespace_id: Id::new_v1(1), + change: Change::Rotate { + expected_generation: 1, + }, + admission_deadline_ts: Some(admission_deadline(10_000, Some(12_000))), + }; + let wire = serde_json::to_vec(&request).unwrap(); + let received: MutationRequest = serde_json::from_slice(&wire).unwrap(); + assert!( + rivet_auth_jwt::ops::webhook::mutate::check_admission( + received.admission_deadline_ts, + 11_999 + ) + .is_ok() + ); + assert!( + rivet_auth_jwt::ops::webhook::mutate::check_admission( + received.admission_deadline_ts, + 12_000 + ) + .is_err() + ); +} diff --git a/engine/packages/api-types/src/auth/application_tokens.rs b/engine/packages/api-types/src/auth/application_tokens.rs new file mode 100644 index 0000000000..690b842d01 --- /dev/null +++ b/engine/packages/api-types/src/auth/application_tokens.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use utoipa::ToSchema; + +#[derive(Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthApplicationTokenCreateRequest)] +pub struct CreateRequest { + pub audience: String, + pub purpose: String, + pub duration: Option, + pub subject: Option, + pub claims: Map, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[schema(as = AuthApplicationTokenCreateResponse)] +pub struct CreateResponse { + pub token: String, + pub issued_ts: i64, + pub expires_ts: i64, +} diff --git a/engine/packages/api-types/src/auth/issuers.rs b/engine/packages/api-types/src/auth/issuers.rs new file mode 100644 index 0000000000..4f83ea53bd --- /dev/null +++ b/engine/packages/api-types/src/auth/issuers.rs @@ -0,0 +1,65 @@ +use acl::types::{OperationKind, ResourceKind, TargetScope}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthIssuerGrant)] +pub struct Grant { + pub resource: ResourceKind, + pub target: TargetScope, + pub operations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthIssuerUpsertRequest)] +pub struct UpsertRequest { + /// Exact `iss` claim to trust for this namespace. + pub issuer: String, + /// Required, exact `aud` claim for Rivet (never optional). + pub audience: String, + pub jwks_url: String, + /// Pinned signature algorithm: EdDSA, RS256, or ES256. + pub algorithm: String, + /// Maximum access this issuer may embed in a signed Rivet JWT. + pub grant_ceiling: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthIssuer)] +pub struct Issuer { + pub name: String, + pub issuer: String, + pub audience: String, + pub jwks_url: String, + pub algorithm: String, + pub grant_ceiling: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthIssuerListResponse)] +pub struct ListResponse { + pub issuers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthIssuerGetResponse)] +pub struct GetResponse { + pub issuer: Issuer, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthIssuerUpsertResponse)] +pub struct UpsertResponse { + pub issuer: Issuer, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthIssuerDeleteResponse)] +pub struct DeleteResponse {} diff --git a/engine/packages/api-types/src/auth/mod.rs b/engine/packages/api-types/src/auth/mod.rs index 5c76635567..f2e69fdf5a 100644 --- a/engine/packages/api-types/src/auth/mod.rs +++ b/engine/packages/api-types/src/auth/mod.rs @@ -1 +1,4 @@ +pub mod application_tokens; +pub mod issuers; pub mod tokens; +pub mod webhooks; diff --git a/engine/packages/api-types/src/auth/webhooks.rs b/engine/packages/api-types/src/auth/webhooks.rs new file mode 100644 index 0000000000..0686c48f0c --- /dev/null +++ b/engine/packages/api-types/src/auth/webhooks.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[schema(as = AuthWebhookKeyStatus)] +pub struct KeyStatus { + pub generation: u64, + pub active_kid: String, + pub pending_kid: Option, + pub last_emergency_request_id: Option, + pub last_emergency_generation: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthWebhookRotateRequest)] +pub struct RotateRequest { + pub expected_generation: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +#[serde(deny_unknown_fields)] +#[schema(as = AuthWebhookRevokeRequest)] +pub struct RevokeRequest { + pub request_id: String, + pub expected_generation: u64, + pub kids: Vec, +} + +#[derive(Clone, Serialize, Deserialize, ToSchema)] +#[schema(as = AuthWebhookSignResponse)] +pub struct SignResponse { + /// Canonical decimal Unix seconds, generated by the control plane. + pub timestamp: String, + pub kid: String, + /// Unpadded base64url Ed25519 signature of timestamp + '.' + raw request body. + pub signature: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[schema(as = AuthWebhookPublicKey)] +pub struct PublicKey { + pub kty: String, + pub crv: String, + pub kid: String, + #[serde(rename = "use")] + pub public_key_use: String, + pub alg: String, + pub x: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[schema(as = AuthWebhookJwks)] +pub struct Jwks { + pub keys: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[schema(as = AuthWebhookMetadata)] +pub struct Metadata { + pub namespace_id: rivet_util::Id, + pub alg: String, + pub kid: String, + pub jwks_url: String, + pub generation: u64, +} + +/// Exact request bytes; this schema must remain binary rather than a JSON integer array. +pub struct WebhookPayload; +impl utoipa::PartialSchema for WebhookPayload { + fn schema() -> utoipa::openapi::RefOr { + use utoipa::openapi::schema::{KnownFormat, ObjectBuilder, SchemaFormat, Type}; + ObjectBuilder::new() + .schema_type(Type::String) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Binary))) + .into() + } +} +impl utoipa::ToSchema for WebhookPayload {} diff --git a/engine/packages/auth-jwt/Cargo.toml b/engine/packages/auth-jwt/Cargo.toml index 01a200b595..e607fa2094 100644 --- a/engine/packages/auth-jwt/Cargo.toml +++ b/engine/packages/auth-jwt/Cargo.toml @@ -9,11 +9,13 @@ license.workspace = true default = [] service = [ "dep:epoxy", + "dep:namespace", "dep:epoxy-protocol", "dep:gas", "dep:futures-util", "dep:lazy_static", "dep:rivet-config", + "dep:reqwest", "dep:rivet-metrics", "dep:rivet-util", "dep:tokio", @@ -30,8 +32,10 @@ epoxy-protocol = { workspace = true, optional = true } gas = { workspace = true, optional = true } futures-util = { workspace = true, optional = true } jsonwebtoken.workspace = true +namespace = { workspace = true, optional = true } lazy_static = { workspace = true, optional = true } rand.workspace = true +reqwest = { workspace = true, optional = true } rivet-auth-policy.workspace = true rivet-config = { workspace = true, optional = true } rivet-data.workspace = true @@ -46,6 +50,7 @@ thiserror.workspace = true tokio = { workspace = true, optional = true } tracing = { workspace = true, optional = true } universaldb = { workspace = true, optional = true } +url.workspace = true vbare.workspace = true zeroize.workspace = true diff --git a/engine/packages/auth-jwt/src/application.rs b/engine/packages/auth-jwt/src/application.rs new file mode 100644 index 0000000000..d4dd678d7b --- /dev/null +++ b/engine/packages/auth-jwt/src/application.rs @@ -0,0 +1,190 @@ +//! The downstream-only JWT profile. Application data never becomes Engine authority. +use jsonwebtoken::{Algorithm, Header}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::{MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES, PROTOCOL_MAX_TTL, SigningKey, TokenError}; + +pub const TOKEN_TYPE: &str = "rivet-app+jwt"; +pub const MAX_APPLICATION_CLAIMS_BYTES: usize = 3_072; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Claims { + pub rivet_ver: u16, + pub rivet_purpose: String, + pub iss: String, + pub aud: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, + pub iat: u64, + pub exp: u64, + pub jti: String, + pub rivet_ns: String, + pub app: Map, +} + +impl std::fmt::Debug for Claims { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ApplicationClaims { [redacted] }") + } +} + +pub fn validate_application_data( + audience: &str, + purpose: &str, + subject: Option<&str>, + app: &Map, + engine_audience: &str, +) -> Result<(), TokenError> { + if audience.trim().is_empty() + || audience.len() > 256 + || audience == "rivet-api" + || audience == engine_audience + || purpose.trim().is_empty() + || purpose.len() > 128 + || reserved_prefix(purpose) + || subject.is_some_and(|s| s.len() > MAX_SUBJECT_BYTES) + || app.len() > 8 + || app.iter().any(|(name, value)| { + !valid_namespace(name) || !value.is_object() || !within_depth(value, 2) + }) { + return Err(TokenError::InvalidClaims); + } + if serde_json::to_vec(app) + .map_err(|_| TokenError::InvalidClaims)? + .len() > MAX_APPLICATION_CLAIMS_BYTES + { + return Err(TokenError::TokenTooLarge); + } + Ok(()) +} + +fn reserved_prefix(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.starts_with("rivet") || lower.starts_with("engine") +} + +fn valid_namespace(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && !reserved_prefix(name) + && ![ + "iss", "sub", "aud", "exp", "nbf", "iat", "jti", "app", "typ", "alg", "kid", + ] + .contains(&name.to_ascii_lowercase().as_str()) + && name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c)) +} + +fn within_depth(value: &Value, depth: usize) -> bool { + match value { + Value::Object(values) => depth <= 8 && values.values().all(|v| within_depth(v, depth + 1)), + Value::Array(values) => depth <= 8 && values.iter().all(|v| within_depth(v, depth + 1)), + _ => true, + } +} + +pub fn encode( + key: &SigningKey, + claims: &Claims, + engine_audience: &str, +) -> Result { + validate_application_data( + &claims.aud, + &claims.rivet_purpose, + claims.sub.as_deref(), + &claims.app, + engine_audience, + )?; + if claims.rivet_ver != 1 + || claims.iss.is_empty() + || claims.iss.len() > 512 + || claims.rivet_ns.parse::().is_err() + || claims.jti.parse::().is_err() + || claims.exp <= claims.iat + || claims.exp - claims.iat > PROTOCOL_MAX_TTL + { + return Err(TokenError::InvalidClaims); + } + let mut header = Header::new(Algorithm::EdDSA); + header.typ = Some(TOKEN_TYPE.into()); + header.kid = Some(key.kid().to_string()); + let token = jsonwebtoken::encode(&header, claims, &key.encoding_key()?) + .map_err(|_| TokenError::InvalidKey)?; + if token.len() > MAX_TOKEN_BYTES { + return Err(TokenError::TokenTooLarge); + } + Ok(token) +} + +#[cfg(feature = "service")] +pub mod service { + use super::*; + use anyhow::{Result, ensure}; + use rand::rngs::OsRng; + + pub struct IssueRequest { + pub namespace_id: crate::Id, + pub audience: String, + pub purpose: String, + pub subject: Option, + pub app: Map, + pub expires_no_later_than_ts: i64, + } + + #[derive(Serialize)] + pub struct IssueResponse { + pub token: String, + #[serde(skip)] + pub kid: String, + #[serde(skip)] + pub generation: u64, + pub issued_ts: i64, + pub expires_ts: i64, + } + + impl crate::key_ring_cache::KeyRingCache { + pub async fn issue_application( + &self, + request: &IssueRequest, + max_duration_secs: u64, + ) -> Result { + ensure!( + (1..=PROTOCOL_MAX_TTL).contains(&max_duration_secs), + "invalid application token maximum duration" + ); + self.with_active_signer(|signer| { + let iat = u64::try_from(signer.issued_ts / 1000)?; + let exp = u64::try_from(request.expires_no_later_than_ts / 1000)? + .min(iat + max_duration_secs); + let claims = Claims { + rivet_ver: 1, + rivet_purpose: request.purpose.clone(), + iss: signer.issuer.to_owned(), + aud: request.audience.clone(), + sub: request.subject.clone(), + iat, + exp, + jti: crate::TokenId::generate(&mut OsRng).to_string(), + rivet_ns: request.namespace_id.to_string(), + app: request.app.clone(), + }; + let token = encode(signer.key, &claims, signer.audience)?; + Ok(IssueResponse { + token, + kid: signer.key.kid().to_string(), + generation: signer.generation, + issued_ts: signer.issued_ts, + expires_ts: i64::try_from(exp)? * 1000, + }) + }) + .await + } + } +} + +#[cfg(test)] +#[path = "../tests/unit/application.rs"] +mod tests; diff --git a/engine/packages/auth-jwt/src/codec.rs b/engine/packages/auth-jwt/src/codec.rs index fcc9a00133..13a7b09185 100644 --- a/engine/packages/auth-jwt/src/codec.rs +++ b/engine/packages/auth-jwt/src/codec.rs @@ -112,6 +112,16 @@ pub fn peek_header(token: &str) -> Result { } pub fn is_reserved_token(token: &str) -> bool { + has_token_type(token, |typ| { + matches!(typ, TOKEN_TYPE | crate::application::TOKEN_TYPE) + }) +} + +pub fn is_application_token(token: &str) -> bool { + has_token_type(token, |typ| typ == crate::application::TOKEN_TYPE) +} + +fn has_token_type(token: &str, matches_type: fn(&str) -> bool) -> bool { let bytes = token.as_bytes(); let Some(first_dot) = bytes .iter() @@ -149,11 +159,12 @@ pub fn is_reserved_token(token: &str) -> bool { let reserved = Cell::new(false); let mut deserializer = serde_json::Deserializer::from_slice(&header); - let _ = de::Deserializer::deserialize_map(&mut deserializer, TypVisitor(&reserved)); + let _ = + de::Deserializer::deserialize_map(&mut deserializer, TypVisitor(&reserved, matches_type)); reserved.get() } -struct TypVisitor<'a>(&'a Cell); +struct TypVisitor<'a>(&'a Cell, fn(&str) -> bool); impl<'de> de::Visitor<'de> for TypVisitor<'_> { type Value = (); @@ -169,7 +180,7 @@ impl<'de> de::Visitor<'de> for TypVisitor<'_> { while let Some(key) = map.next_key::()? { if key == "typ" { let value = map.next_value::()?; - if value.as_str() == Some(TOKEN_TYPE) { + if value.as_str().is_some_and(self.1) { self.0.set(true); } } else { @@ -232,7 +243,7 @@ fn decode_segment( Ok(decoded) } -fn validate_claims( +pub(crate) fn validate_claims( claims: &Claims, options: Option>, ) -> Result<(Id, Vec>), TokenError> { diff --git a/engine/packages/auth-jwt/src/external_issuers.rs b/engine/packages/auth-jwt/src/external_issuers.rs new file mode 100644 index 0000000000..62f99322b7 --- /dev/null +++ b/engine/packages/auth-jwt/src/external_issuers.rs @@ -0,0 +1,175 @@ +//! Namespace-bound registrations for externally signed Rivet access JWTs. +//! +//! A valid signature alone never grants access: the issuer registration constrains the +//! namespace, audience, and the maximum set of signed grants that Rivet will honor. + +use std::collections::HashSet; + +use anyhow::{Context, Result, ensure}; +use rivet_auth_policy::OwnedGrant; +use rivet_data::{ + AUTH_JWT_EXTERNAL_ISSUERS_VERSION, generated::auth_jwt_external_issuers_v1, + versioned::AuthJwtExternalIssuersData, +}; +use url::{Host, Url}; +use vbare::OwnedVersionedData; + +use crate::{Id, ValidatedGrantSet, decode_grants, encode_grants}; + +pub const MAX_ISSUERS_PER_NAMESPACE: usize = 8; + +#[derive(Clone, Debug)] +pub struct ExternalIssuer { + pub name: String, + pub issuer: String, + pub audience: String, + pub jwks_url: String, + pub algorithm: String, + pub grant_ceiling: Vec>, +} + +#[derive(Clone, Debug)] +pub struct ExternalIssuerRegistry { + pub namespace_id: Id, + pub generation: u64, + pub issuers: Vec, +} + +impl ExternalIssuerRegistry { + pub fn empty(namespace_id: Id) -> Self { + Self { + namespace_id, + generation: 0, + issuers: Vec::new(), + } + } + + pub fn validate(&self) -> Result<()> { + ensure!( + self.issuers.len() <= MAX_ISSUERS_PER_NAMESPACE, + "too many JWT issuers" + ); + let mut names = HashSet::new(); + let mut issuers = HashSet::new(); + for entry in &self.issuers { + ensure!( + !entry.name.is_empty() + && entry.name.len() <= 64 + && entry + .name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-'), + "invalid JWT issuer name" + ); + ensure!( + names.insert(entry.name.as_str()), + "duplicate JWT issuer name" + ); + ensure!( + issuers.insert(entry.issuer.as_str()), + "duplicate JWT issuer" + ); + ensure!( + !entry.issuer.is_empty() && entry.issuer.len() <= 512, + "invalid JWT issuer" + ); + let issuer = Url::parse(&entry.issuer).context("invalid JWT issuer URL")?; + ensure!( + issuer.scheme() == "https" + && issuer.host_str().is_some() + && issuer.username().is_empty() + && issuer.password().is_none() + && issuer.query().is_none() + && issuer.fragment().is_none(), + "JWT issuer must be an HTTPS URL without credentials, query, or fragment" + ); + ensure!( + !entry.audience.is_empty() && entry.audience.len() <= 256, + "invalid JWT audience" + ); + ensure!( + !entry.jwks_url.is_empty() && entry.jwks_url.len() <= 2_048, + "invalid JWKS URL" + ); + let jwks_url = Url::parse(&entry.jwks_url).context("invalid JWKS URL")?; + ensure!( + jwks_url.scheme() == "https" + && jwks_url.port_or_known_default() == Some(443) + && matches!(jwks_url.host(), Some(Host::Domain(_))) + && jwks_url.username().is_empty() + && jwks_url.password().is_none() + && jwks_url.query().is_none() + && jwks_url.fragment().is_none(), + "JWKS URL must use an HTTPS domain on port 443 without credentials, query, or fragment" + ); + ensure!( + matches!(entry.algorithm.as_str(), "EdDSA" | "RS256" | "ES256"), + "unsupported JWKS signing algorithm" + ); + ValidatedGrantSet::new(self.namespace_id, entry.grant_ceiling.clone()) + .context("invalid JWT issuer grant ceiling")?; + } + Ok(()) + } + + pub fn encode(&self) -> Result> { + self.validate()?; + let issuers = self + .issuers + .iter() + .map(|entry| { + let ceiling = + ValidatedGrantSet::new(self.namespace_id, entry.grant_ceiling.clone())?; + Ok(auth_jwt_external_issuers_v1::Issuer { + name: entry.name.clone(), + issuer: entry.issuer.clone(), + audience: entry.audience.clone(), + jwks_url: entry.jwks_url.clone(), + algorithm: entry.algorithm.clone(), + grant_ceiling: encode_grants(&ceiling)?, + }) + }) + .collect::>>()?; + Ok( + AuthJwtExternalIssuersData::wrap_latest(auth_jwt_external_issuers_v1::Data { + namespace_id: self.namespace_id.as_bytes(), + generation: self.generation, + issuers, + }) + .serialize_with_embedded_version(AUTH_JWT_EXTERNAL_ISSUERS_VERSION)?, + ) + } + + pub fn decode(namespace_id: Id, data: &[u8]) -> Result { + let decoded = AuthJwtExternalIssuersData::deserialize_with_embedded_version(data)?; + ensure!( + Id::from_slice(&decoded.namespace_id)? == namespace_id, + "JWT issuer registry namespace mismatch" + ); + let issuers = decoded + .issuers + .into_iter() + .map(|entry| { + Ok(ExternalIssuer { + name: entry.name, + issuer: entry.issuer, + audience: entry.audience, + jwks_url: entry.jwks_url, + algorithm: entry.algorithm, + grant_ceiling: decode_grants(namespace_id, &entry.grant_ceiling)?, + }) + }) + .collect::>>()?; + let registry = Self { + namespace_id, + generation: decoded.generation, + issuers, + }; + registry.validate()?; + Ok(registry) + } +} + +#[cfg(test)] +#[path = "../tests/unit/external_issuers.rs"] +mod tests; diff --git a/engine/packages/auth-jwt/src/grants.rs b/engine/packages/auth-jwt/src/grants.rs index 4ca922ede9..9456721edf 100644 --- a/engine/packages/auth-jwt/src/grants.rs +++ b/engine/packages/auth-jwt/src/grants.rs @@ -28,7 +28,7 @@ impl ValidatedGrantSet { } if grants.iter().any(|grant| { !matches!(grant.namespace, Scope::Id(id) if id == namespace_id) - || matches!(grant.resource, ResourceKind::Jwt | ResourceKind::Token) + || matches!(grant.resource, ResourceKind::Jwt | ResourceKind::Token | ResourceKind::ApplicationToken | ResourceKind::WebhookSigning) || grant.operations.is_empty() || grant.operations.len() > MAX_OPERATIONS_PER_GRANT }) { @@ -182,7 +182,7 @@ fn encode_resource(resource: ResourceKind) -> auth_grants_v1::ResourceKind { ResourceKind::Datacenter => auth_grants_v1::ResourceKind::Datacenter, ResourceKind::ActorGateway => auth_grants_v1::ResourceKind::ActorGateway, ResourceKind::ActorKv => auth_grants_v1::ResourceKind::ActorKv, - ResourceKind::Jwt => unreachable!("control-plane grants are rejected during validation"), + ResourceKind::Jwt | ResourceKind::ApplicationToken | ResourceKind::WebhookSigning => unreachable!("control-plane grants are rejected during validation"), } } diff --git a/engine/packages/auth-jwt/src/jwks.rs b/engine/packages/auth-jwt/src/jwks.rs new file mode 100644 index 0000000000..05df87b71f --- /dev/null +++ b/engine/packages/auth-jwt/src/jwks.rs @@ -0,0 +1,53 @@ +//! Standards-shaped, public-only JWKS representation of Rivet-issued token verification keys. + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::Serialize; + +use crate::key_ring_cache::{VerificationKeyEntry, VerificationKeyLifecycle}; + +#[derive(Debug, PartialEq, Eq, Serialize)] +pub struct JwkSet { + pub keys: Vec, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +pub struct Jwk { + pub kty: &'static str, + pub crv: &'static str, + #[serde(rename = "use")] + pub public_key_use: &'static str, + pub alg: &'static str, + pub kid: String, + pub x: String, +} + +impl JwkSet { + /// Includes keys needed for verification, never private signing material. A revoked key is + /// removed from the authoritative ring, and a retired key disappears at its verification + /// deadline; pending keys are published before they start signing. + pub fn from_verification_keys(keys: &[VerificationKeyEntry], now_ms: i64) -> Self { + let keys = keys + .iter() + .filter(|key| match &key.lifecycle { + VerificationKeyLifecycle::Retiring { + verify_until_ts, .. + } => now_ms < *verify_until_ts, + VerificationKeyLifecycle::Pending { .. } + | VerificationKeyLifecycle::Active { .. } => true, + }) + .map(|key| Jwk { + kty: "OKP", + crv: "Ed25519", + public_key_use: "sig", + alg: "EdDSA", + kid: key.kid.clone(), + x: URL_SAFE_NO_PAD.encode(&key.public_key), + }) + .collect(); + Self { keys } + } +} + +#[cfg(test)] +#[path = "../tests/unit/jwks.rs"] +mod tests; diff --git a/engine/packages/auth-jwt/src/key_ring_cache/external.rs b/engine/packages/auth-jwt/src/key_ring_cache/external.rs new file mode 100644 index 0000000000..389f4f2ce6 --- /dev/null +++ b/engine/packages/auth-jwt/src/key_ring_cache/external.rs @@ -0,0 +1,625 @@ +//! Verifies explicitly registered, namespace-bound externally signed JWTs. +//! +//! Unverified `iss` and `rivet_ns` are lookup hints only. Neither grants access until the +//! signature, exact issuer/audience, bounded expiry, signed grants, and configured grant ceiling +//! have all been checked. This path never falls back to ACL. + +use std::{ + collections::BTreeMap, + future::Future, + net::{IpAddr, SocketAddr}, + pin::Pin, + str::FromStr, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result, bail, ensure}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, jwk::Jwk}; +use rivet_auth_policy::can_delegate; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::{ + Claims, DecodedToken, ExternalIssuerRegistry, Id, PROTOCOL_CLOCK_SKEW, TokenError, VerifiedJwt, + codec::validate_claims, +}; + +use super::{KeyRingCache, VerificationFailure}; + +const CACHE_TTL: Duration = Duration::from_secs(30); +const UNKNOWN_KID_COOLDOWN: Duration = Duration::from_secs(5); +const FETCH_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_CACHED_ISSUERS: usize = 128; +const MAX_COLD_ISSUERS: usize = 16; +const FAILED_REFRESH_COOLDOWN: Duration = Duration::from_secs(5); +const MAX_JWKS_BYTES: usize = 32 * 1024; +const MAX_JWKS_KEYS: usize = 16; +const MAX_EXTERNAL_TOKEN_BYTES: usize = 8 * 1024; + +type RegistryFuture = Pin> + Send>>; +pub(super) type RegistryFetcher = Arc RegistryFuture + Send + Sync>; +type JwksFuture = Pin>> + Send>>; +pub(super) type JwksFetcher = Arc JwksFuture + Send + Sync>; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StrictHeader { + typ: String, + alg: String, + kid: String, +} + +#[derive(Deserialize)] +struct JwksDocument { + // RFC 7517 allows additional JWK Set members and requires readers to ignore them. + keys: Vec, +} + +pub(super) struct ExternalIssuerCache { + pub(super) fetched_at: Instant, + pub(super) last_unknown_kid: Option, + pub(super) registry: ExternalIssuerRegistry, + pub(super) keys: BTreeMap<(String, String), DecodingKey>, +} + +pub(super) struct ExternalIssuerEntry { + pub(super) state: tokio::sync::Mutex, + first_seen: Instant, + verified: AtomicBool, +} + +#[derive(Default)] +pub(super) struct ExternalIssuerState { + pub(super) cached: Option, + pub(super) last_failed_refresh: Option, + last_cold_result: Option, +} + +impl ExternalIssuerEntry { + fn new() -> Self { + Self { + state: tokio::sync::Mutex::new(ExternalIssuerState::default()), + first_seen: Instant::now(), + verified: AtomicBool::new(false), + } + } +} + +pub(super) fn unverified_claims(token: &str) -> Option { + if token.len() > MAX_EXTERNAL_TOKEN_BYTES { + return None; + } + let (_, remainder) = token.split_once('.')?; + let (payload, _) = remainder.split_once('.')?; + if payload.len() > 6_144 { + return None; + } + let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?; + serde_json::from_slice(&decoded).ok() +} + +fn header(token: &str) -> Result { + if token.len() > MAX_EXTERNAL_TOKEN_BYTES { + return Err(VerificationFailure::InvalidToken); + } + let (header, _) = token + .split_once('.') + .ok_or(VerificationFailure::InvalidToken)?; + if header.len() > 512 { + return Err(VerificationFailure::InvalidToken); + } + let bytes = URL_SAFE_NO_PAD + .decode(header) + .map_err(|_| VerificationFailure::InvalidToken)?; + let header: StrictHeader = + serde_json::from_slice(&bytes).map_err(|_| VerificationFailure::InvalidToken)?; + if header.typ != crate::TOKEN_TYPE + || header.kid.is_empty() + || header.kid.len() > 128 + || !header.kid.is_ascii() + { + return Err(VerificationFailure::InvalidToken); + } + Ok(header) +} + +impl KeyRingCache { + pub(super) async fn verify_external( + &self, + token: &str, + unverified: Claims, + ) -> Result { + let header = header(token)?; + let namespace_id = + Id::from_str(&unverified.rivet_ns).map_err(|_| VerificationFailure::InvalidToken)?; + if unverified.iss.len() > 512 { + return Err(VerificationFailure::InvalidToken); + } + let cache_key = (namespace_id, unverified.iss.clone()); + // A lock per issuer coalesces its refreshes without letting an unavailable issuer + // block verification using another issuer's already cached keys. + let entry = self.external_entry(&cache_key).await?; + let mut state = entry.state.lock().await; + let needs_refresh = state + .cached + .as_ref() + .is_none_or(|entry| entry.fetched_at.elapsed() > CACHE_TTL); + if needs_refresh { + self.refresh_external_entry(&mut state, &cache_key).await?; + } + let issuer_name = { + let cached = state + .cached + .as_ref() + .ok_or(VerificationFailure::InvalidToken)?; + cached + .registry + .issuers + .iter() + .find(|entry| entry.issuer == unverified.iss) + .map(|entry| entry.name.clone()) + }; + let Some(issuer_name) = issuer_name else { + entry.verified.store(false, Ordering::Release); + return Err(VerificationFailure::InvalidToken); + }; + let key = (issuer_name.clone(), header.kid.clone()); + if !state + .cached + .as_ref() + .is_some_and(|entry| entry.keys.contains_key(&key)) + { + let last_unknown = state + .cached + .as_ref() + .and_then(|entry| entry.last_unknown_kid); + if last_unknown.is_none_or(|when| when.elapsed() >= UNKNOWN_KID_COOLDOWN) { + if let Some(entry) = state.cached.as_mut() { + entry.last_unknown_kid = Some(Instant::now()); + } + self.refresh_external_entry(&mut state, &cache_key).await?; + } + } + let cached = state + .cached + .as_ref() + .ok_or(VerificationFailure::InvalidToken)?; + let issuer = cached + .registry + .issuers + .iter() + .find(|entry| entry.name == issuer_name && entry.issuer == unverified.iss); + let Some(issuer) = issuer else { + entry.verified.store(false, Ordering::Release); + return Err(VerificationFailure::InvalidToken); + }; + if header.alg != issuer.algorithm { + return Err(VerificationFailure::InvalidToken); + } + let decoding_key = cached + .keys + .get(&key) + .ok_or(VerificationFailure::InvalidToken)?; + let algorithm = match issuer.algorithm.as_str() { + "EdDSA" => Algorithm::EdDSA, + "RS256" => Algorithm::RS256, + "ES256" => Algorithm::ES256, + _ => return Err(VerificationFailure::InvalidToken), + }; + let mut validation = Validation::new(algorithm); + validation.required_spec_claims.clear(); + validation.validate_exp = false; + validation.validate_aud = false; + validation.leeway = 0; + let decoded = jsonwebtoken::decode::(token, decoding_key, &validation) + .map_err(|_| VerificationFailure::InvalidToken)?; + let now = u64::try_from(rivet_util::timestamp::now() / 1000) + .map_err(|_| VerificationFailure::VerificationUnavailable)?; + let (token_namespace, grants) = validate_claims( + &decoded.claims, + Some(crate::DecodeOptions { + issuer: &issuer.issuer, + audience: &issuer.audience, + now, + }), + ) + .map_err(|error| match error { + TokenError::Expired => VerificationFailure::Expired, + _ => VerificationFailure::InvalidToken, + })?; + if token_namespace != namespace_id + || decoded.claims.sub.as_deref().is_none_or(str::is_empty) + || !can_delegate(&issuer.grant_ceiling, &grants) + { + return Err(VerificationFailure::InvalidToken); + } + // External `kid` is opaque. A deterministic, issuer-qualified fingerprint serves as + // internal diagnostic metadata without conflating it with a Rivet signing key ID. + let digest = + Sha256::digest([issuer.issuer.as_bytes(), b":", header.kid.as_bytes()].concat()); + let mut kid_fingerprint = [0; crate::keys::KEY_ID_BYTES]; + kid_fingerprint.copy_from_slice(&digest[..crate::keys::KEY_ID_BYTES]); + entry.verified.store(true, Ordering::Release); + self.trim_verified_cache(&cache_key, &entry).await; + Ok(VerifiedJwt { + authorization_deadline: decoded.claims.exp.saturating_add(PROTOCOL_CLOCK_SKEW), + token: DecodedToken { + claims: decoded.claims, + namespace_id, + grants, + }, + kid: crate::KeyId::from_bytes(kid_fingerprint), + }) + } + + async fn external_entry( + &self, + cache_key: &(Id, String), + ) -> Result, VerificationFailure> { + let entry = { + let mut caches = self.external_cache.lock().await; + if let Some(entry) = caches.get(&cache_key) { + Arc::clone(entry) + } else { + let cold_count = caches + .values() + .filter(|entry| !entry.verified.load(Ordering::Acquire)) + .count(); + if cold_count >= MAX_COLD_ISSUERS { + // Unverified hints may consume a small cold budget, but cannot evict an + // in-flight refresh or a verified issuer's warm keys. A newly verified + // issuer may replace an old verified entry only after validation. + let expired = caches + .iter() + .find(|(_, entry)| { + !entry.verified.load(Ordering::Acquire) + // The map must be the sole owner. A caller can hold an Arc before it + // acquires the state lock, so try_lock alone cannot protect it. + && Arc::strong_count(entry) == 1 + && entry.state.try_lock().is_ok_and(|state| { + // A cancelled first refresh never records a result. Once its caller + // drops the Arc, admission time still lets us reclaim the slot. + state + .last_cold_result + .unwrap_or(entry.first_seen) + .elapsed() + >= FAILED_REFRESH_COOLDOWN + }) + }) + .map(|(key, _)| key.clone()); + if let Some(expired) = expired { + caches.remove(&expired); + } else { + return Err(VerificationFailure::VerificationUnavailable); + } + } + let entry = Arc::new(ExternalIssuerEntry::new()); + caches.insert(cache_key.clone(), Arc::clone(&entry)); + entry + } + }; + Ok(entry) + } + + async fn trim_verified_cache( + &self, + cache_key: &(Id, String), + entry: &Arc, + ) { + let mut caches = self.external_cache.lock().await; + if !caches + .get(cache_key) + .is_some_and(|current| Arc::ptr_eq(current, entry)) + { + return; + } + let verified_count = caches + .values() + .filter(|entry| entry.verified.load(Ordering::Acquire)) + .count(); + if verified_count > MAX_CACHED_ISSUERS { + let evicted = caches + .iter() + .find(|(key, entry)| *key != cache_key && entry.verified.load(Ordering::Acquire)) + .map(|(key, _)| key.clone()); + if let Some(evicted) = evicted { + caches.remove(&evicted); + } + } + } + + async fn refresh_external_entry( + &self, + state: &mut ExternalIssuerState, + cache_key: &(Id, String), + ) -> Result<(), VerificationFailure> { + if state + .last_failed_refresh + .is_some_and(|when| when.elapsed() < FAILED_REFRESH_COOLDOWN) + { + return self.stale_external(&state.cached); + } + let result = self.refresh_external(&mut state.cached, cache_key).await; + let completed_at = Instant::now(); + state.last_cold_result = Some(completed_at); + match result { + Ok(true) => state.last_failed_refresh = None, + Ok(false) => state.last_failed_refresh = Some(completed_at), + Err(error) => { + state.last_failed_refresh = Some(completed_at); + return Err(error); + } + } + Ok(()) + } + + async fn refresh_external( + &self, + cached: &mut Option, + cache_key: &(Id, String), + ) -> Result { + let refreshed = async { + // Epoxy waits up to REQUEST_TIMEOUT for a blackholed replica before it can + // select the newest value from the replicas that did answer. Do not cut + // that read short with the smaller outbound JWKS HTTP timeout. + let registry = tokio::time::timeout( + epoxy::consts::REQUEST_TIMEOUT + FETCH_TIMEOUT, + (self.external_fetch_registry)(cache_key.0), + ) + .await??; + // Prevent rollback within this process. An empty/evicted cache has no generation floor: + // a stale reachable replica can delay issuer revocation until a current replica returns. + // max_stale only bounds fetch failures, not successful reads of stale committed data. + ensure!( + cached + .as_ref() + .is_none_or(|previous| registry.generation >= previous.registry.generation), + "JWT issuer registry generation regressed" + ); + let mut keys = BTreeMap::new(); + for issuer in registry + .issuers + .iter() + .filter(|entry| entry.issuer == cache_key.1) + { + for (kid, key) in + tokio::time::timeout(FETCH_TIMEOUT, (self.external_fetch_jwks)(issuer.clone())) + .await?? + { + ensure!( + keys.insert((issuer.name.clone(), kid), key).is_none(), + "duplicate issuer kid" + ); + } + } + Ok::<_, anyhow::Error>(ExternalIssuerCache { + fetched_at: Instant::now(), + last_unknown_kid: cached + .as_ref() + .and_then(|previous| previous.last_unknown_kid), + registry, + keys, + }) + } + .await; + match refreshed { + Ok(entry) => { + *cached = Some(entry); + Ok(true) + } + Err(error) => { + tracing::warn!(?error, "external JWT verifier refresh failed"); + self.stale_external(cached)?; + Ok(false) + } + } + } + + fn stale_external( + &self, + cached: &Option, + ) -> Result<(), VerificationFailure> { + if cached + .as_ref() + .is_some_and(|entry| entry.fetched_at.elapsed() <= self.max_stale) + { + Ok(()) + } else { + Err(VerificationFailure::VerificationUnavailable) + } + } +} + +pub(super) async fn fetch_jwks( + issuer: &crate::ExternalIssuer, +) -> Result> { + let url = reqwest::Url::parse(&issuer.jwks_url)?; + ensure!( + url.scheme() == "https" && url.port_or_known_default() == Some(443), + "JWKS URL must use HTTPS port 443" + ); + let host = url.host_str().context("JWKS URL is missing a host")?; + let addresses = tokio::net::lookup_host((host, 443)) + .await? + .collect::>(); + ensure!( + !addresses.is_empty() && addresses.iter().all(|addr| public_ip(addr.ip())), + "JWKS URL resolved outside the public internet" + ); + // Pin this particular fetch to the validated DNS answers. Disabling redirects also blocks a + // public endpoint from redirecting to a private address or a different issuer. + let client = jwks_client(host, &addresses)?; + let mut response = client.get(url).send().await?.error_for_status()?; + ensure!(response.status().is_success(), "JWKS fetch did not succeed"); + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + ensure!( + bytes.len().saturating_add(chunk.len()) <= MAX_JWKS_BYTES, + "JWKS document too large" + ); + bytes.extend_from_slice(&chunk); + } + parse_jwks(&bytes, &issuer.algorithm) +} + +fn jwks_client(host: &str, addresses: &[SocketAddr]) -> Result { + Ok(reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .resolve_to_addrs(host, addresses) + .timeout(FETCH_TIMEOUT) + .build()?) +} + +fn parse_jwks(bytes: &[u8], algorithm: &str) -> Result> { + ensure!(bytes.len() <= MAX_JWKS_BYTES, "JWKS document too large"); + let document: JwksDocument = serde_json::from_slice(bytes)?; + ensure!( + document.keys.len() <= MAX_JWKS_KEYS, + "invalid JWKS key count" + ); + let mut keys = BTreeMap::new(); + for value in document.keys { + let object = value.as_object().context("JWKS key must be an object")?; + // Ignore extension metadata as required by RFC 7517, but never accept a JWK that + // publishes standard private (or symmetric) key components. + ensure!( + !object.keys().any(|key| matches!( + key.as_str(), + "d" | "p" | "q" | "dp" | "dq" | "qi" | "oth" | "k" + )), + "JWKS key contains private material" + ); + // A key set may publish other algorithms or encryption keys. They cannot verify + // this issuer's pinned algorithm, so ignore them rather than rejecting the set. + if object + .get("alg") + .is_some_and(|v| v.as_str() != Some(algorithm)) + || object.get("use").is_some_and(|v| v.as_str() != Some("sig")) + || object.get("key_ops").is_some_and(|ops| { + !ops.as_array() + .is_some_and(|ops| ops.len() == 1 && ops[0].as_str() == Some("verify")) + }) { + continue; + } + let compatible_type = match algorithm { + "EdDSA" => { + object.get("kty").and_then(|v| v.as_str()) == Some("OKP") + && object.get("crv").and_then(|v| v.as_str()) == Some("Ed25519") + } + "ES256" => { + object.get("kty").and_then(|v| v.as_str()) == Some("EC") + && object.get("crv").and_then(|v| v.as_str()) == Some("P-256") + } + "RS256" => object.get("kty").and_then(|v| v.as_str()) == Some("RSA"), + _ => bail!("unsupported external signing algorithm"), + }; + if !compatible_type { + continue; + } + let kid = object + .get("kid") + .and_then(|v| v.as_str()) + .context("JWKS key is missing kid")? + .to_owned(); + ensure!( + !kid.is_empty() && kid.len() <= 128 && kid.is_ascii(), + "invalid JWKS key identifier" + ); + match algorithm { + "EdDSA" => { + ensure!( + jwk_component(object, "x")?.len() == 32, + "invalid Ed25519 key length" + ); + } + "ES256" => { + ensure!( + jwk_component(object, "x")?.len() == 32 + && jwk_component(object, "y")?.len() == 32, + "invalid P-256 key length" + ); + } + "RS256" => { + ensure!( + jwk_component(object, "n")?.len() >= 256, + "RSA signing keys must be at least 2048 bits" + ); + let exponent = jwk_component(object, "e")?; + ensure!( + !exponent.is_empty() + && exponent.len() <= 4 + && exponent.last().is_some_and(|byte| byte & 1 == 1), + "invalid RSA exponent" + ); + } + _ => bail!("unsupported external signing algorithm"), + } + let jwk: Jwk = serde_json::from_value(value)?; + let key = DecodingKey::from_jwk(&jwk)?; + ensure!( + keys.insert(kid, key).is_none(), + "duplicate JWKS key identifier" + ); + } + Ok(keys) +} + +fn jwk_component( + object: &serde_json::Map, + name: &str, +) -> Result> { + let encoded = object + .get(name) + .and_then(|v| v.as_str()) + .context("missing public JWK component")?; + ensure!( + !encoded.is_empty() && encoded.len() <= 1_024 && !encoded.contains('='), + "invalid public JWK component" + ); + let bytes = URL_SAFE_NO_PAD.decode(encoded)?; + ensure!( + URL_SAFE_NO_PAD.encode(&bytes) == encoded, + "non-canonical public JWK component" + ); + Ok(bytes) +} + +fn public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + let [a, b, c, _] = ip.octets(); + // Conservative allowlist of public unicast space; reject private, link-local, + // documentation, benchmarking, carrier NAT, multicast, and reserved ranges. + (1..=223).contains(&a) + && a != 10 && a != 127 + && !(a == 100 && (64..=127).contains(&b)) + && !(a == 169 && b == 254) + && !(a == 172 && (16..=31).contains(&b)) + && !(a == 192 && (b == 168 || b == 0 || (b == 88 && c == 99))) + && !(a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100))) + && !(a == 203 && b == 0 && c == 113) + } + IpAddr::V6(ip) => { + if let Some(mapped) = ip.to_ipv4_mapped() { + return public_ip(IpAddr::V4(mapped)); + } + let segments = ip.segments(); + // IPv6 global-unicast /3, excluding special-purpose and documentation ranges. + (segments[0] & 0xe000) == 0x2000 + && !(segments[0] == 0x2001 && segments[1] <= 0x01ff) + && !(segments[0] == 0x2001 && segments[1] == 0x0db8) + && segments[0] != 0x2002 + && segments[0] != 0x3fff + } + } +} + +#[cfg(test)] +#[path = "../../tests/unit/key_ring_cache_external.rs"] +mod tests; diff --git a/engine/packages/auth-jwt/src/key_ring_cache/mod.rs b/engine/packages/auth-jwt/src/key_ring_cache/mod.rs index 4124d63732..5e4ff32732 100644 --- a/engine/packages/auth-jwt/src/key_ring_cache/mod.rs +++ b/engine/packages/auth-jwt/src/key_ring_cache/mod.rs @@ -1,12 +1,14 @@ //! One published key-ring generation for runtime JWT verification and optional issuance. //! Verification allows bounded stale reads; signing uses its own short freshness lease. +mod external; mod refresh; mod signing; mod snapshot; mod validation; use std::{ + collections::BTreeMap, sync::{ Arc, atomic::{AtomicBool, AtomicI64, AtomicU8}, @@ -23,6 +25,7 @@ pub use snapshot::{ }; use crate::{PROTOCOL_CLOCK_SKEW, VerifiedJwt}; +use external::{JwksFetcher, RegistryFetcher}; use refresh::{SnapshotFetcher, fetch_snapshot_from_epoxy, periodic_refresh}; use snapshot::CachedSnapshot; use validation::{decode_with_snapshot, map_token_error, validate_lifecycle}; @@ -82,6 +85,9 @@ pub struct KeyRingCache { last_unknown_refresh_ts: AtomicI64, last_refresh_failure_ts: AtomicI64, fallback_state: AtomicU8, + external_fetch_registry: RegistryFetcher, + external_fetch_jwks: JwksFetcher, + external_cache: Mutex>>, } impl KeyRingCache { @@ -93,6 +99,7 @@ impl KeyRingCache { ); let (snapshot_tx, _) = watch::channel(None); let fetch_ctx = ctx.clone(); + let external_ctx = ctx.clone(); Ok(Arc::new(Self { mode, fetch_snapshot: Arc::new(move || { @@ -108,6 +115,19 @@ impl KeyRingCache { last_unknown_refresh_ts: AtomicI64::new(i64::MIN), last_refresh_failure_ts: AtomicI64::new(i64::MIN), fallback_state: AtomicU8::new(0), + external_fetch_registry: Arc::new(move |namespace_id| { + let ctx = external_ctx.clone(); + Box::pin(async move { + Ok(ctx + .op(crate::ops::external_issuers::get::Input { namespace_id }) + .await? + .registry) + }) + }), + external_fetch_jwks: Arc::new(|issuer| { + Box::pin(async move { external::fetch_jwks(&issuer).await }) + }), + external_cache: Mutex::new(BTreeMap::new()), })) } @@ -147,12 +167,32 @@ impl KeyRingCache { result } + /// Returns only public keys using the verification cache's bounded-staleness policy. + pub async fn public_snapshot( + self: &Arc, + ) -> Result { + Ok(self.usable_snapshot().await?.verification.clone()) + } + async fn verify_inner( self: &Arc, token: &str, ) -> Result { - let header = crate::peek_header(token).map_err(map_token_error)?; + if crate::is_application_token(token) { + return Err(VerificationFailure::InvalidToken); + } let mut snapshot = self.usable_snapshot().await?; + // Untrusted `iss` only selects a verifier. Rivet issuers never fall back to JWKS. + if let Some(claims) = external::unverified_claims(token) + && claims.iss != snapshot.active_issuer + && !snapshot + .retiring_issuers + .iter() + .any(|entry| entry.issuer == claims.iss) + { + return self.verify_external(token, claims).await; + } + let header = crate::peek_header(token).map_err(map_token_error)?; if !snapshot.keys.contains_key(&header.kid) { snapshot = self .refresh_unknown_kid(header.kid) diff --git a/engine/packages/auth-jwt/src/key_ring_cache/signing.rs b/engine/packages/auth-jwt/src/key_ring_cache/signing.rs index fa829dbdd8..ed79cde1ab 100644 --- a/engine/packages/auth-jwt/src/key_ring_cache/signing.rs +++ b/engine/packages/auth-jwt/src/key_ring_cache/signing.rs @@ -14,6 +14,7 @@ pub(crate) struct ActiveSigner<'a> { pub(crate) audience: &'a str, pub(crate) issued_ts: i64, pub(crate) now: i64, + pub(crate) generation: u64, } impl KeyRingCache { @@ -80,6 +81,7 @@ impl CachedSnapshot { Ok(ActiveSigner { key: &active.key.signing_key, + generation: self.generation, issuer: &self.active_issuer, audience: &self.audience, issued_ts: now.div_euclid(1_000) * 1_000, diff --git a/engine/packages/auth-jwt/src/lib.rs b/engine/packages/auth-jwt/src/lib.rs index 337110f733..48d9a097e3 100644 --- a/engine/packages/auth-jwt/src/lib.rs +++ b/engine/packages/auth-jwt/src/lib.rs @@ -1,6 +1,8 @@ +pub mod application; mod claims; mod codec; mod error; +mod external_issuers; mod grants; mod key_ring; mod keys; @@ -12,6 +14,8 @@ mod verified_jwt; #[cfg(feature = "service")] pub mod issuer; #[cfg(feature = "service")] +pub mod jwks; +#[cfg(feature = "service")] pub mod key_ring_cache; #[cfg(feature = "service")] pub mod metrics; @@ -25,9 +29,10 @@ pub mod workflows; pub use claims::{CLAIMS_VERSION, Claims, PROTOCOL_CLOCK_SKEW, PROTOCOL_MAX_TTL}; pub use codec::{ DecodeOptions, DecodedToken, MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES, TokenHeader, decode, encode, - is_reserved_token, peek_header, + is_application_token, is_reserved_token, peek_header, }; pub use error::TokenError; +pub use external_issuers::{ExternalIssuer, ExternalIssuerRegistry, MAX_ISSUERS_PER_NAMESPACE}; pub use grants::{ MAX_GRANTS, MAX_OPERATIONS_PER_GRANT, ValidatedGrantSet, decode_grants, encode_grants, }; @@ -58,3 +63,5 @@ pub const SIGNING_CACHE_LEASE: std::time::Duration = std::time::Duration::from_s pub fn registry() -> gas::prelude::WorkflowResult { workflows::registry() } + +pub mod webhook; diff --git a/engine/packages/auth-jwt/src/metrics.rs b/engine/packages/auth-jwt/src/metrics.rs index 4b8d44f9a9..c77b2827a4 100644 --- a/engine/packages/auth-jwt/src/metrics.rs +++ b/engine/packages/auth-jwt/src/metrics.rs @@ -212,3 +212,10 @@ pub fn record_verifier_refresh(mode: &'static str, result: &'static str, generat fn saturating_i64(value: u64) -> i64 { i64::try_from(value).unwrap_or(i64::MAX) } + +lazy_static::lazy_static! { + pub static ref SIGNING_REQUESTS: IntCounterVec = register_int_counter_vec_with_registry!("rivet_signing_requests_total", "Purpose-specific signing outcomes.", &["operation", "outcome"], *REGISTRY).unwrap(); + pub static ref SIGNING_LATENCY: HistogramVec = register_histogram_vec_with_registry!("rivet_signing_duration_seconds", "Purpose-specific signing request latency.", &["operation", "outcome"], *REGISTRY).unwrap(); + pub static ref SIGNING_BYTES: IntCounterVec = register_int_counter_vec_with_registry!("rivet_signing_bytes_total", "Accepted signing input bytes.", &["operation"], *REGISTRY).unwrap(); + pub static ref WEBHOOK_MUTATIONS: IntCounterVec = register_int_counter_vec_with_registry!("rivet_webhook_key_mutations_total", "Committed webhook key lifecycle mutations.", &["operation"], *REGISTRY).unwrap(); +} diff --git a/engine/packages/auth-jwt/src/ops/external_issuers.rs b/engine/packages/auth-jwt/src/ops/external_issuers.rs new file mode 100644 index 0000000000..22ec2a63b5 --- /dev/null +++ b/engine/packages/auth-jwt/src/ops/external_issuers.rs @@ -0,0 +1,210 @@ +//! Authoritative, versioned namespace JWKS issuer registrations. + +use anyhow::{Context, Result, bail, ensure}; +use epoxy::ops::propose::{ + CheckAndSetCommand, Command, CommandKind, ConsensusFailedReason, Proposal, ProposalResult, +}; +use gas::prelude::*; +use universaldb::prelude::FormalKey; + +use crate::{ + ExternalIssuerRegistry, Id, + storage_keys::{self, ExternalIssuersKey}, +}; + +#[derive(Clone, Debug)] +pub struct Snapshot { + pub encoded: Option>, + pub registry: ExternalIssuerRegistry, +} + +pub mod get { + use super::*; + + #[derive(Debug)] + pub struct Input { + pub namespace_id: Id, + } + + /// Reads the newest reachable committed issuer registry, including its encoded CAS predecessor. + /// This availability-first read can lag a removal or grant-ceiling reduction while a newer + /// replica is isolated; it is not a linearizable revocation check. + #[operation] + pub async fn auth_jwt_external_issuers_get( + ctx: &OperationCtx, + input: &Input, + ) -> Result { + let key = ExternalIssuersKey { + namespace_id: input.namespace_id, + }; + let packed_key = storage_keys::subspace().pack(&key); + let Some(committed) = ctx + .op(epoxy::ops::kv::get_latest::Input { key: packed_key }) + .await? + .value + else { + return Ok(Snapshot { + encoded: None, + registry: ExternalIssuerRegistry::empty(input.namespace_id), + }); + }; + ensure!( + committed.mutable, + "JWT external issuer registry must be mutable" + ); + let encoded = committed + .value + .context("JWT external issuer registry was deleted")?; + let registry = key + .deserialize(&encoded) + .context("invalid JWT external issuer registry")?; + Ok(Snapshot { + encoded: Some(encoded), + registry, + }) + } +} + +pub mod compare_and_set { + use super::*; + + #[derive(Debug)] + pub struct Input { + pub expected: Option>, + pub successor: ExternalIssuerRegistry, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum Output { + Committed, + Conflict, + } + + /// Only the configured leader datacenter may update an issuer registry. This is the same + /// coordinator-owned CAS assumption as the JWT signing ring. A conflicting writer rereads and + /// replans; never apply an obsolete snapshot after a deletion or grant-ceiling reduction. + #[operation] + pub async fn auth_jwt_external_issuers_compare_and_set( + ctx: &OperationCtx, + input: &Input, + ) -> Result { + ensure!( + ctx.config().is_leader(), + "JWT issuer registration writes are leader-only" + ); + match &input.expected { + Some(previous) => { + let registry = + ExternalIssuerRegistry::decode(input.successor.namespace_id, previous)?; + ensure!( + registry.generation.checked_add(1) == Some(input.successor.generation), + "JWT issuer registry generation must advance exactly once" + ); + } + None => ensure!( + input.successor.generation == 1, + "JWT issuer registry must start at generation one" + ), + } + + let key = ExternalIssuersKey { + namespace_id: input.successor.namespace_id, + }; + let packed_key = storage_keys::subspace().pack(&key); + let encoded = key.serialize(input.successor.clone())?; + match ctx + .op(epoxy::ops::propose::Input { + proposal: Proposal { + commands: vec![Command { + kind: CommandKind::CheckAndSetCommand(CheckAndSetCommand { + key: packed_key, + expect_one_of: vec![input.expected.clone()], + new_value: Some(encoded), + }), + }], + }, + mutable: true, + purge_cache: true, + target_replicas: None, + }) + .await? + { + ProposalResult::Committed => Ok(Output::Committed), + ProposalResult::ConsensusFailed { + reason: ConsensusFailedReason::ExpectedValueDoesNotMatch { .. }, + } => Ok(Output::Conflict), + ProposalResult::ConsensusFailed { reason } => { + bail!("issuer registry proposal failed: {reason:?}") + } + } + } +} + +pub mod mutate { + use super::*; + use crate::ExternalIssuer; + + #[derive(Debug)] + pub struct Input { + pub namespace_id: Id, + pub name: String, + /// `None` removes an issuer. A removal is idempotent. + pub replacement: Option, + } + + /// Replans against the newest committed generation after concurrent issuer updates. A + /// removal never writes an older snapshot over a concurrent registration or ceiling change. + #[operation] + pub async fn auth_jwt_external_issuers_mutate( + ctx: &OperationCtx, + input: &Input, + ) -> Result { + ensure!( + ctx.config().is_leader(), + "JWT issuer registration writes are leader-only" + ); + ensure!( + input + .replacement + .as_ref() + .is_none_or(|entry| entry.name == input.name), + "issuer name does not match request path" + ); + for _ in 0..8 { + let snapshot = ctx + .op(get::Input { + namespace_id: input.namespace_id, + }) + .await?; + let mut successor = snapshot.registry; + let existing = successor + .issuers + .iter() + .position(|entry| entry.name == input.name); + match (&input.replacement, existing) { + (None, None) => return Ok(successor), + (None, Some(index)) => { + successor.issuers.remove(index); + } + (Some(entry), Some(index)) => successor.issuers[index] = entry.clone(), + (Some(entry), None) => successor.issuers.push(entry.clone()), + } + successor.generation = successor + .generation + .checked_add(1) + .context("issuer registry generation overflow")?; + successor.validate()?; + match ctx + .op(compare_and_set::Input { + expected: snapshot.encoded, + successor: successor.clone(), + }) + .await? + { + compare_and_set::Output::Committed => return Ok(successor), + compare_and_set::Output::Conflict => continue, + } + } + bail!("JWT issuer registry changed concurrently too many times") + } +} diff --git a/engine/packages/auth-jwt/src/ops/mod.rs b/engine/packages/auth-jwt/src/ops/mod.rs index 81d250c378..0a3d517479 100644 --- a/engine/packages/auth-jwt/src/ops/mod.rs +++ b/engine/packages/auth-jwt/src/ops/mod.rs @@ -1,3 +1,7 @@ +pub mod external_issuers; pub mod issue; pub mod key_ring; pub mod rotation; +pub mod webhook; + +pub mod signing_quota; diff --git a/engine/packages/auth-jwt/src/ops/signing_quota.rs b/engine/packages/auth-jwt/src/ops/signing_quota.rs new file mode 100644 index 0000000000..1f4c91d75e --- /dev/null +++ b/engine/packages/auth-jwt/src/ops/signing_quota.rs @@ -0,0 +1,143 @@ +//! One durable quota budget per immutable namespace, shared by all API replicas. +use anyhow::{Result, ensure}; +use gas::prelude::*; +use rivet_auth_policy::errors::Signing; +use rivet_data::{generated::auth_signing_quota_v1::Data, versioned::AuthSigningQuotaData}; +use vbare::OwnedVersionedData; + +pub const WINDOW_MS: i64 = 60_000; +pub const BYTE_RESERVATION: u64 = 1024 * 1024; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Charge { + Application, + Webhook, + Bytes(u64), +} + +pub fn admit( + state: &mut Data, + charge: Charge, + limits: rivet_config::config::auth::SigningQuota, + now: i64, + dc: u16, + generation: u64, +) -> Result<()> { + ensure!( + generation >= state.leader_generation + && (generation > state.leader_generation || dc == state.leader_datacenter), + "stale quota authority" + ); + if now.saturating_sub(state.window_start) >= WINDOW_MS { + state.window_start = now; + state.application_operations = 0; + state.webhook_operations = 0; + state.webhook_bytes = 0; + } + // Backwards time cannot start a new window or replenish its remaining budget. + let (counter, limit, amount) = match charge { + Charge::Application => ( + &mut state.application_operations, + limits.application_operations, + 1, + ), + Charge::Webhook => (&mut state.webhook_operations, limits.webhook_operations, 1), + Charge::Bytes(bytes) => (&mut state.webhook_bytes, limits.webhook_bytes, bytes), + }; + let next = counter + .checked_add(amount) + .ok_or_else(|| Signing::Throttled.build())?; + if next > limit { + return Err(Signing::Throttled.build()); + } + *counter = next; + state.leader_datacenter = dc; + state.leader_generation = generation; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Input { + pub namespace_id: Id, + pub charge: Charge, +} + +#[operation] +pub async fn auth_signing_quota(ctx: &OperationCtx, input: &Input) -> Result<()> { + ensure!( + ctx.config().is_leader(), + "signing quota authority is leader-only" + ); + let auth = ctx.config().auth_required()?; + let generation = auth.jwt.issuer_generation(); + let dc = ctx.config().dc_label(); + let limits = auth.jwt.signing.quota(&input.namespace_id.to_string()); + let key = crate::storage_keys::subspace().pack(&("signing_quota", input.namespace_id)); + for _ in 0..16 { + let read = ctx + .op(epoxy::ops::kv::get::Input { + key: key.clone(), + mode: epoxy::ops::kv::get::ReadMode::Linearizable { + target_replicas: None, + }, + }) + .await?; + let previous = match read.value { + Some(value) => { + ensure!(value.mutable, "quota state must be mutable"); + Some(value.value.ok_or_else(|| Signing::Unavailable.build())?) + } + None => None, + }; + let now = rivet_util::timestamp::now(); + let mut state = if let Some(bytes) = previous.as_ref() { + ensure!(bytes.len() <= 128, "invalid quota state"); + AuthSigningQuotaData::deserialize_with_embedded_version(bytes)? + } else { + Data { + window_start: now, + application_operations: 0, + webhook_operations: 0, + webhook_bytes: 0, + leader_datacenter: dc, + leader_generation: generation, + } + }; + admit(&mut state, input.charge, limits, now, dc, generation)?; + let encoded = AuthSigningQuotaData::wrap_latest(state) + .serialize_with_embedded_version(rivet_data::generated::AUTH_SIGNING_QUOTA_VERSION)?; + let result = ctx + .op(epoxy::ops::propose::Input { + proposal: epoxy::ops::propose::Proposal { + commands: vec![epoxy::ops::propose::Command { + kind: epoxy::ops::propose::CommandKind::CheckAndSetCommand( + epoxy::ops::propose::CheckAndSetCommand { + key: key.clone(), + expect_one_of: vec![previous], + new_value: Some(encoded), + }, + ), + }], + }, + mutable: true, + purge_cache: true, + target_replicas: None, + }) + .await?; + match result { + epoxy::ops::propose::ProposalResult::Committed => return Ok(()), + epoxy::ops::propose::ProposalResult::ConsensusFailed { + reason: epoxy::ops::propose::ConsensusFailedReason::ExpectedValueDoesNotMatch { .. }, + } => continue, + // Ambiguous commits must never be retried as an uncharged admission. + _ => return Err(Signing::Unavailable.build()), + } + } + Err(Signing::Unavailable.build()) +} + +#[cfg(test)] +#[path = "../../tests/unit/signing_quota.rs"] +mod tests; diff --git a/engine/packages/auth-jwt/src/ops/webhook.rs b/engine/packages/auth-jwt/src/ops/webhook.rs new file mode 100644 index 0000000000..7a4db22ea2 --- /dev/null +++ b/engine/packages/auth-jwt/src/ops/webhook.rs @@ -0,0 +1,277 @@ +//! Leader-owned, linearizable namespace key-ring reads and compare-and-set mutations. +use crate::webhook::{Error, Ring}; +use anyhow::{Context, Result, ensure}; +use gas::prelude::*; + +pub(crate) fn key(namespace_id: Id) -> Vec { + crate::storage_keys::subspace().pack(&("webhook", namespace_id)) +} + +#[derive(Clone)] +pub struct Snapshot { + pub ring: Ring, + pub encoded: Vec, + pub read_started: std::time::Instant, +} +impl std::fmt::Debug for Snapshot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WebhookSnapshot") + .field("ring", &self.ring) + .finish_non_exhaustive() + } +} + +pub mod get { + use super::*; + #[derive(Debug)] + pub struct Input { + pub namespace_id: Id, + } + #[operation] + pub async fn auth_webhook_get(ctx: &OperationCtx, input: &Input) -> Result> { + ensure!( + ctx.config().is_leader(), + "webhook key reads are leader-only" + ); + let read_started = std::time::Instant::now(); + let value = ctx + .op(epoxy::ops::kv::get::Input { + key: key(input.namespace_id), + mode: epoxy::ops::kv::get::ReadMode::Linearizable { + target_replicas: None, + }, + }) + .await? + .value; + ensure!( + read_started.elapsed() < crate::SIGNING_CACHE_LEASE, + "webhook key read expired" + ); + value + .map(|value| { + ensure!(value.mutable, "webhook key record must be mutable"); + let encoded = value.value.context("webhook key record was deleted")?; + let ring = Ring::decode(input.namespace_id, &encoded)?; + Ok(Snapshot { + ring, + encoded, + read_started, + }) + }) + .transpose() + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum Change { + Ensure, + Reconcile, + ReconcileTopology, + Rotate { + expected_generation: u64, + }, + Revoke { + request_id: [u8; 16], + expected_generation: u64, + kids: Vec, + }, +} + +pub mod mutate { + use super::*; + #[derive(Debug)] + pub struct Input { + pub namespace_id: Id, + pub change: Change, + /// Deadline established by public authorization. Internal lifecycle work has no credential. + pub admission_deadline_ts: Option, + } + + pub fn check_admission(deadline: Option, now: i64) -> Result<()> { + if deadline.is_some_and(|deadline| now >= deadline) { + return Err(Error::Unavailable.into()); + } + Ok(()) + } + + #[operation] + pub async fn auth_webhook_mutate(ctx: &OperationCtx, input: &Input) -> Result { + check_admission(input.admission_deadline_ts, rivet_util::timestamp::now())?; + ensure!( + ctx.config().is_leader(), + "webhook key mutations are leader-only" + ); + ensure!( + ctx.config().auth_required()?.jwt.issuance_enabled() + || matches!( + input.change, + Change::Revoke { .. } | Change::Reconcile | Change::ReconcileTopology + ), + "signing disabled" + ); + ensure!( + ctx.config().protocols().acl_rule.version() >= 3, + "webhook schema rollout incomplete" + ); + if !ctx + .op(namespace::ops::exists_authoritative::Input { + namespace_id: input.namespace_id, + }) + .await? + { + return Err(Error::NotFound.into()); + } + let dc = ctx.config().dc_label(); + let config_generation = ctx.config().auth_required()?.jwt.issuer_generation(); + for _ in 0..16 { + check_admission(input.admission_deadline_ts, rivet_util::timestamp::now())?; + let snapshot = ctx + .op(get::Input { + namespace_id: input.namespace_id, + }) + .await?; + let now = rivet_util::timestamp::now(); + check_admission(input.admission_deadline_ts, now)?; + let successor = match snapshot.as_ref() { + None if matches!(input.change, Change::Ensure) => { + Ring::bootstrap(input.namespace_id, dc, config_generation, now)? + } + None => return Err(Error::NotFound.into()), + Some(snapshot) => { + let ring = &snapshot.ring; + match &input.change { + Change::Ensure | Change::Reconcile | Change::ReconcileTopology => { + match if matches!(input.change, Change::ReconcileTopology) { + ring.reconcile_topology(dc, config_generation, now)? + } else { + ring.reconcile(dc, config_generation, now)? + } { + Some(successor) => successor, + None => return Ok(ring.clone()), + } + } + Change::Rotate { + expected_generation, + } => { + ensure!( + ring.keys.leader_datacenter_id == dc + && ring.keys.leader_config_generation == config_generation, + "webhook topology is not reconciled" + ); + ring.rotate(*expected_generation, now)? + } + Change::Revoke { + request_id, + expected_generation, + kids, + } => { + ensure!( + ring.keys.leader_datacenter_id == dc + && ring.keys.leader_config_generation == config_generation, + "webhook topology is not reconciled" + ); + ring.revoke(*request_id, *expected_generation, kids, now)? + } + } + } + }; + if snapshot.as_ref().is_some_and(|s| s.ring == successor) { + return Ok(successor); + } + if let Some(previous) = &snapshot { + previous.ring.keys.validate_successor(&successor.keys)?; + } + let encoded = successor.encode()?; + // The deadline bounds admission to consensus, not its eventual acknowledgment. + // Every retry must earn a new admission; never submit after authorization expires. + check_admission(input.admission_deadline_ts, rivet_util::timestamp::now())?; + let result = ctx + .op(epoxy::ops::propose::Input { + proposal: epoxy::ops::propose::Proposal { + commands: vec![epoxy::ops::propose::Command { + kind: epoxy::ops::propose::CommandKind::CheckAndSetCommand( + epoxy::ops::propose::CheckAndSetCommand { + key: key(input.namespace_id), + expect_one_of: vec![ + snapshot.as_ref().map(|s| s.encoded.clone()), + ], + new_value: Some(encoded), + }, + ), + }], + }, + mutable: true, + purge_cache: true, + target_replicas: None, + }) + .await?; + match result { + epoxy::ops::propose::ProposalResult::Committed => { + let operation = match input.change { + Change::Ensure => "ensure", + Change::Reconcile | Change::ReconcileTopology => "automatic", + Change::Rotate { .. } => "rotate", + Change::Revoke { .. } => "revoke", + }; + crate::metrics::WEBHOOK_MUTATIONS + .with_label_values(&[operation]) + .inc(); + if !matches!( + input.change, + Change::Ensure | Change::Reconcile | Change::ReconcileTopology + ) { + return Ok(successor); + } + } + epoxy::ops::propose::ProposalResult::ConsensusFailed { + reason: + epoxy::ops::propose::ConsensusFailedReason::ExpectedValueDoesNotMatch { .. }, + } => {} + _ if matches!( + input.change, + Change::Ensure | Change::Reconcile | Change::ReconcileTopology + ) => + { + // The rotation worker may preempt first-use admission. A fresh linearizable + // read resolves accepted state before this idempotent reconciliation retries. + // Explicit operator mutations preserve uncertain-commit errors instead. + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + _ => return Err(Error::Unavailable.into()), + } + } + Err(Error::Unavailable.into()) + } +} + +#[cfg(test)] +#[path = "../../tests/unit/webhook_admission.rs"] +mod tests; + +/// Dispatch first so an interrupted successful provision never lacks an idle rotation worker. +pub async fn ensure_workflow(ctx: &StandaloneCtx, namespace_id: Id) -> Result<()> { + ctx.op(ensure_rotation::Input { namespace_id }).await +} +pub mod ensure_rotation { + use super::*; + #[derive(Debug)] + pub struct Input { + pub namespace_id: Id, + } + #[operation] + pub async fn auth_webhook_ensure_rotation(ctx: &OperationCtx, input: &Input) -> Result<()> { + ensure!( + ctx.config().is_leader(), + "webhook workflows are leader-owned" + ); + ctx.workflow(crate::workflows::webhook_rotation::Input { + namespace_id: input.namespace_id, + }) + .tag("namespace_id", input.namespace_id) + .unique() + .dispatch() + .await?; + Ok(()) + } +} diff --git a/engine/packages/auth-jwt/src/storage_keys.rs b/engine/packages/auth-jwt/src/storage_keys.rs index 94b7222072..7ee72b6f6a 100644 --- a/engine/packages/auth-jwt/src/storage_keys.rs +++ b/engine/packages/auth-jwt/src/storage_keys.rs @@ -1,7 +1,9 @@ use anyhow::Result; use universaldb::prelude::*; -use crate::{SigningKeyRing, decode_signing_key_ring, encode_signing_key_ring}; +use crate::{ + ExternalIssuerRegistry, SigningKeyRing, decode_signing_key_ring, encode_signing_key_ring, +}; pub fn subspace() -> universaldb::utils::Subspace { universaldb::utils::Subspace::new(&(RIVET, AUTH, JWT)) @@ -44,6 +46,51 @@ impl<'de> TupleUnpack<'de> for SigningKeyRingKey { } } +/// One consensus-managed issuer registry per immutable namespace ID. The configured leader +/// datacenter is the sole mutation owner; other datacenters can read replicated versions. +#[derive(Debug, Clone, Copy)] +pub struct ExternalIssuersKey { + pub namespace_id: crate::Id, +} + +impl FormalKey for ExternalIssuersKey { + type Value = ExternalIssuerRegistry; + + fn deserialize(&self, raw: &[u8]) -> Result { + ExternalIssuerRegistry::decode(self.namespace_id, raw) + } + + fn serialize(&self, value: Self::Value) -> Result> { + anyhow::ensure!( + self.namespace_id == value.namespace_id, + "issuer registry key namespace mismatch" + ); + value.encode() + } +} + +impl TuplePack for ExternalIssuersKey { + fn pack( + &self, + w: &mut W, + tuple_depth: TupleDepth, + ) -> std::io::Result { + (EXTERNAL_ISSUERS, self.namespace_id).pack(w, tuple_depth) + } +} + +impl<'de> TupleUnpack<'de> for ExternalIssuersKey { + fn unpack(input: &[u8], tuple_depth: TupleDepth) -> PackResult<(&[u8], Self)> { + let (input, (tag, namespace_id)) = <(usize, crate::Id)>::unpack(input, tuple_depth)?; + if tag != EXTERNAL_ISSUERS { + return Err(PackError::Message( + "expected JWT external issuers key".into(), + )); + } + Ok((input, Self { namespace_id })) + } +} + #[cfg(test)] #[path = "../tests/unit/storage_keys.rs"] mod tests; diff --git a/engine/packages/auth-jwt/src/webhook.rs b/engine/packages/auth-jwt/src/webhook.rs new file mode 100644 index 0000000000..8d4140bc52 --- /dev/null +++ b/engine/packages/auth-jwt/src/webhook.rs @@ -0,0 +1,411 @@ +//! Namespace-separated webhook keys. Internal random IDs remain private to key-ring bookkeeping; +//! public IDs are the DS-compatible thumbprints and never enter authentication-key discovery. +use crate::{Id, KeyId, PublicKeyRecord, RotationPolicy, SigningKeyRecord, SigningKeyRing}; +use anyhow::{Context, Result, ensure}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use rivet_data::{ + AUTH_WEBHOOK_KEY_RING_VERSION, generated::auth_webhook_key_ring_v1 as data, + versioned::AuthWebhookKeyRingData, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use vbare::OwnedVersionedData; + +pub const FRESHNESS_SECONDS: u64 = 300; +// Stable internal planner identity, not a JWT issuer or a public discovery endpoint. +const PLANNER_ISSUER: &str = "https://rivet.dev/webhook-signing"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EmergencyReceipt { + pub request_id: [u8; 16], + pub expected_generation: u64, + pub committed_generation: u64, + pub requested_kids: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Ring { + pub namespace_id: Id, + pub keys: SigningKeyRing, + pub last_emergency: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid webhook key request")] + InvalidInput, + #[error("webhook key generation or request ID conflict")] + Conflict, + #[error("webhook signing keys are not provisioned")] + NotFound, + #[error("webhook signing keys are unavailable")] + Unavailable, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Jwk { + pub kty: String, + pub crv: String, + pub kid: String, + #[serde(rename = "use")] + pub public_key_use: String, + pub alg: String, + pub x: String, +} + +pub fn public_kid(public: &[u8; 32]) -> String { + let x = URL_SAFE_NO_PAD.encode(public); + let canonical = format!(r#"{{"crv":"Ed25519","kty":"OKP","x":"{x}"}}"#); + format!( + "ds_{}", + URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes())) + ) +} + +pub fn policy() -> RotationPolicy { + RotationPolicy { + rotation_interval_ms: 7 * 86400 * 1000, + publish_lead_ms: 600 * 1000, + max_signing_lifetime_ms: 14 * 86400 * 1000, + max_token_ttl_ms: FRESHNESS_SECONDS as i64 * 1000, + clock_skew_ms: 30 * 1000, + } +} + +impl Ring { + pub fn bootstrap(namespace_id: Id, dc: u16, config_generation: u64, now: i64) -> Result { + Ok(Self { + namespace_id, + keys: crate::bootstrap( + PLANNER_ISSUER.into(), + config_generation, + "rivet-webhooks".into(), + dc, + 1, + &SigningKeyRecord::generate(now), + now, + policy(), + )? + .successor, + last_emergency: None, + }) + } + + pub fn encode(&self) -> Result> { + self.validate()?; + AuthWebhookKeyRingData::wrap_latest(data::Data { + namespace_id: self.namespace_id.as_bytes(), + ring: crate::encode_signing_key_ring(&self.keys)?, + last_emergency: self + .last_emergency + .as_ref() + .map(|r| data::EmergencyReceipt { + request_id: r.request_id.to_vec(), + expected_generation: r.expected_generation, + committed_generation: r.committed_generation, + requested_kids: r.requested_kids.clone(), + }), + }) + .serialize_with_embedded_version(AUTH_WEBHOOK_KEY_RING_VERSION) + } + + pub fn decode(namespace_id: Id, bytes: &[u8]) -> Result { + ensure!( + bytes.len() <= 64 * 1024, + "webhook key ring exceeds storage bound" + ); + let data = AuthWebhookKeyRingData::deserialize_with_embedded_version(bytes)?; + ensure!( + Id::from_slice(&data.namespace_id)? == namespace_id, + "webhook key namespace mismatch" + ); + let value = Self { + namespace_id, + keys: crate::decode_signing_key_ring(&data.ring)?, + last_emergency: data + .last_emergency + .map(|r| -> Result<_> { + Ok(EmergencyReceipt { + request_id: r.request_id.try_into().map_err(|_| Error::InvalidInput)?, + expected_generation: r.expected_generation, + committed_generation: r.committed_generation, + requested_kids: r.requested_kids, + }) + }) + .transpose()?, + }; + value.validate()?; + Ok(value) + } + + pub fn validate(&self) -> Result<()> { + self.keys.validate()?; + ensure!( + self.keys.issuer_state.active.issuer == PLANNER_ISSUER + && self.keys.audience == "rivet-webhooks", + "invalid webhook ring identity" + ); + if let Some(r) = &self.last_emergency { + ensure!( + !r.requested_kids.is_empty() + && r.requested_kids.len() <= crate::ABSOLUTE_KEY_LIMIT + && r.requested_kids + .iter() + .all(|id| id.len() == 46 && id.starts_with("ds_")) + && r.requested_kids.windows(2).all(|pair| pair[0] < pair[1]) + && r.expected_generation.checked_add(1) == Some(r.committed_generation) + && r.committed_generation <= self.keys.generation, + "invalid webhook emergency receipt" + ); + } + Ok(()) + } + + pub fn public_keys(&self, now: i64) -> Vec { + self.public_records(now) + .into_iter() + .map(|k| Jwk { + kty: "OKP".into(), + crv: "Ed25519".into(), + kid: public_kid(&k.public_key), + public_key_use: "sig".into(), + alg: "EdDSA".into(), + x: URL_SAFE_NO_PAD.encode(k.public_key), + }) + .collect() + } + + fn public_records(&self, now: i64) -> Vec<&PublicKeyRecord> { + let mut keys = vec![&self.keys.active.key.public]; + if let Some(pending) = &self.keys.pending { + keys.push(&pending.key.public); + } + keys.extend( + self.keys + .retiring + .iter() + .filter(|key| now < key.verify_until_ts) + .map(|key| &key.key), + ); + keys + } + + /// One planner transition per CAS. Topology generation fences the old leader. + pub fn reconcile_topology( + &self, + dc: u16, + config_generation: u64, + now: i64, + ) -> Result> { + let transition = match crate::reconcile_issuer( + &self.keys, + PLANNER_ISSUER, + dc, + config_generation, + &[], + now, + policy(), + )? { + crate::IssuerReconcile::StaleConfiguration => return Err(Error::Unavailable.into()), + crate::IssuerReconcile::Transition(t) => Some(t), + crate::IssuerReconcile::Stable if self.keys.leader_datacenter_id != dc => { + Some(crate::recover_leader( + &self.keys, + dc, + self.keys + .leader_epoch + .checked_add(1) + .context("leader epoch overflow")?, + config_generation, + )?) + } + crate::IssuerReconcile::Stable => None, + }; + Ok(transition.map(|t| Self { + keys: t.successor, + ..self.clone() + })) + } + pub fn reconcile(&self, dc: u16, config_generation: u64, now: i64) -> Result> { + let transition = match crate::reconcile_issuer( + &self.keys, + PLANNER_ISSUER, + dc, + config_generation, + &[], + now, + policy(), + )? { + crate::IssuerReconcile::StaleConfiguration => return Err(Error::Unavailable.into()), + crate::IssuerReconcile::Transition(t) => Some(t), + crate::IssuerReconcile::Stable if self.keys.leader_datacenter_id != dc => { + Some(crate::recover_leader( + &self.keys, + dc, + self.keys + .leader_epoch + .checked_add(1) + .context("leader epoch overflow")?, + config_generation, + )?) + } + crate::IssuerReconcile::Stable => { + if let Some(t) = crate::prune_retiring(&self.keys, now)? { + Some(t) + } else if self + .keys + .pending + .as_ref() + .is_some_and(|key| now >= key.activate_after_ts) + { + Some(crate::activate_pending(&self.keys, now, policy())?) + } else if self.keys.pending.is_none() + && now + >= self.keys.active.activated_ts + policy().rotation_interval_ms + - policy().publish_lead_ms + { + Some(crate::stage_pending( + &self.keys, + &SigningKeyRecord::generate(now), + now, + policy(), + )?) + } else { + None + } + } + }; + Ok(transition.map(|t| Self { + keys: t.successor, + ..self.clone() + })) + } + + pub fn rotate(&self, expected_generation: u64, now: i64) -> Result { + if self.keys.generation != expected_generation { + return Err(Error::Conflict.into()); + } + if self.keys.pending.is_some() { + return Ok(self.clone()); + } + Ok(Self { + keys: crate::stage_pending_forced( + &self.keys, + &SigningKeyRecord::generate(now), + now, + policy(), + )? + .successor, + ..self.clone() + }) + } + + pub fn revoke( + &self, + request_id: [u8; 16], + expected_generation: u64, + requested_kids: &[String], + now: i64, + ) -> Result { + let mut requested = requested_kids.to_vec(); + requested.sort(); + if requested.is_empty() + || requested.len() > crate::ABSOLUTE_KEY_LIMIT + || requested.windows(2).any(|w| w[0] == w[1]) + { + return Err(Error::InvalidInput.into()); + } + // Compare original public arguments before resolving IDs removed by the first commit. + if let Some(receipt) = &self.last_emergency { + if receipt.request_id == request_id { + return if receipt.expected_generation == expected_generation + && receipt.requested_kids == requested + { + Ok(self.clone()) + } else { + Err(Error::Conflict.into()) + }; + } + } + if self.keys.generation != expected_generation { + return Err(Error::Conflict.into()); + } + let keys: Vec = requested + .iter() + .map(|id| { + self.public_records(now) + .into_iter() + .find(|key| public_kid(&key.public_key) == *id) + .map(|key| key.kid) + .ok_or(Error::InvalidInput) + }) + .collect::>()?; + let successor = crate::emergency_rotate( + &self.keys, + &SigningKeyRecord::generate(now), + request_id, + expected_generation, + &keys, + now, + policy(), + )? + .successor; + Ok(Self { + namespace_id: self.namespace_id, + last_emergency: Some(EmergencyReceipt { + request_id, + expected_generation, + committed_generation: successor.generation, + requested_kids: requested, + }), + keys: successor, + }) + } +} + +#[cfg(test)] +#[path = "../tests/unit/webhook.rs"] +mod tests; + +pub const MAX_PAYLOAD_BYTES: usize = 128 * 1024 * 1024; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Signature { + pub timestamp: String, + pub kid: String, + pub signature: String, +} +impl std::fmt::Debug for Signature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WebhookSignature") + .field("timestamp", &self.timestamp) + .field("kid", &self.kid) + .finish_non_exhaustive() + } +} + +/// Plain Ed25519 over decimal Unix seconds, '.', and the unchanged raw body. +/// Production callers supply Engine time and enforce their authoritative signing lease. +pub fn sign(ring: &Ring, body: &[u8], now: i64) -> Result { + use ed25519_dalek::Signer; + ensure!( + body.len() <= MAX_PAYLOAD_BYTES, + "webhook payload exceeds maximum" + ); + ensure!( + now >= ring.keys.active.activated_ts && now < ring.keys.active.sign_until_ts, + "webhook signer is outside its signing lifetime" + ); + let timestamp = u64::try_from(now / 1000)?.to_string(); + let mut input = Vec::with_capacity(timestamp.len() + 1 + body.len()); + input.extend_from_slice(timestamp.as_bytes()); + input.push(b'.'); + input.extend_from_slice(body); + let key = ed25519_dalek::SigningKey::from_bytes(ring.keys.active.key.signing_key.seed()); + let signature = key.sign(&input); + Ok(Signature { + timestamp, + kid: public_kid(&key.verifying_key().to_bytes()), + signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()), + }) +} diff --git a/engine/packages/auth-jwt/src/workflows/mod.rs b/engine/packages/auth-jwt/src/workflows/mod.rs index 83956bada9..790d2ccd9c 100644 --- a/engine/packages/auth-jwt/src/workflows/mod.rs +++ b/engine/packages/auth-jwt/src/workflows/mod.rs @@ -1,9 +1,13 @@ pub mod key_rotation; +pub mod webhook_recovery; +pub mod webhook_rotation; use gas::prelude::*; pub fn registry() -> WorkflowResult { let mut registry = Registry::new(); registry.register_workflow::()?; + registry.register_workflow::()?; + registry.register_workflow::()?; Ok(registry) } diff --git a/engine/packages/auth-jwt/src/workflows/webhook_recovery.rs b/engine/packages/auth-jwt/src/workflows/webhook_recovery.rs new file mode 100644 index 0000000000..5607f10497 --- /dev/null +++ b/engine/packages/auth-jwt/src/workflows/webhook_recovery.rs @@ -0,0 +1,89 @@ +//! Redispatch provisioned idle rings on the current authority, independent of the former DC. +use futures_util::FutureExt; +use gas::prelude::*; +#[derive(Debug, Serialize, Deserialize)] +pub struct Input; +#[derive(Debug, Clone, Serialize, Deserialize, Hash)] +struct Page { + after: Option>, +} +#[workflow] +pub async fn auth_webhook_recovery(ctx: &mut WorkflowCtx, _input: &Input) -> Result<()> { + ctx.loope(None::>, |ctx, after| { + async move { + let next = ctx + .activity(Page { + after: after.clone(), + }) + .await?; + if next.is_none() { + ctx.sleep(std::time::Duration::from_secs(60)).await?; + } + *after = next; + Ok(Loop::<()>::Continue) + } + .boxed() + }) + .await?; + Ok(()) +} +#[activity(WebhookRecoveryPage)] +#[max_retries = usize::MAX] +async fn page(ctx: &ActivityCtx, input: &Page) -> Result>> { + if !ctx.config().is_leader() || ctx.config().protocols().acl_rule.version() < 3 { + return Ok(None); + } + let page = ctx + .op(namespace::ops::signing_recovery_page::Input { + after: input.after.clone(), + }) + .await?; + Ok(recover_page( + page, + |namespace_id| async move { + Ok(ctx + .op(crate::ops::webhook::get::Input { namespace_id }) + .await? + .is_some()) + }, + |namespace_id| async move { + ctx.op(crate::ops::webhook::ensure_rotation::Input { namespace_id }) + .await + }, + ) + .await) +} + +/// A broken ring must not prevent unrelated namespaces from recovering. Advance past failures +/// and retry them on the next complete sweep; never replace or provision their key material. +async fn recover_page( + page: namespace::ops::signing_recovery_page::Output, + mut provisioned: Read, + mut dispatch: Dispatch, +) -> Option> +where + Read: FnMut(Id) -> ReadFuture, + ReadFuture: std::future::Future>, + Dispatch: FnMut(Id) -> DispatchFuture, + DispatchFuture: std::future::Future>, +{ + for namespace_id in page.namespace_ids { + match provisioned(namespace_id).await { + Ok(false) => {} + Ok(true) => { + if dispatch(namespace_id).await.is_err() { + tracing::warn!(namespace_id = %namespace_id, phase = "dispatch", "webhook recovery will retry namespace on the next sweep"); + } + } + Err(_) => { + // Decode/storage errors may contain private persisted data: never log them. + tracing::warn!(namespace_id = %namespace_id, phase = "read", "webhook recovery will retry namespace on the next sweep"); + } + } + } + page.next +} + +#[cfg(test)] +#[path = "../../tests/unit/webhook_recovery.rs"] +mod tests; diff --git a/engine/packages/auth-jwt/src/workflows/webhook_rotation.rs b/engine/packages/auth-jwt/src/workflows/webhook_rotation.rs new file mode 100644 index 0000000000..660f0f0367 --- /dev/null +++ b/engine/packages/auth-jwt/src/workflows/webhook_rotation.rs @@ -0,0 +1,66 @@ +use futures_util::FutureExt; +use gas::prelude::*; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Input { + pub namespace_id: Id, +} + +#[workflow] +pub async fn auth_webhook_rotation(ctx: &mut WorkflowCtx, input: &Input) -> Result<()> { + let namespace_id = input.namespace_id; + ctx.loope((), |ctx, _| { + async move { + let next = ctx.activity(ReconcileInput { namespace_id }).await?; + match next { + Some(next) => { + ctx.sleep_until(next).await?; + Ok(Loop::<()>::Continue) + } + None => Ok(Loop::Break(())), + } + } + .boxed() + }) + .await?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, Hash)] +struct ReconcileInput { + namespace_id: Id, +} + +#[activity(WebhookReconcile)] +#[max_retries = usize::MAX] +async fn reconcile(ctx: &ActivityCtx, input: &ReconcileInput) -> Result> { + if !ctx.config().is_leader() { + return Ok(Some(rivet_util::timestamp::now() + 60_000)); + } + if !ctx + .op(namespace::ops::exists_authoritative::Input { + namespace_id: input.namespace_id, + }) + .await? + { + return Ok(None); + } + match ctx + .op(crate::ops::webhook::mutate::Input { + namespace_id: input.namespace_id, + change: crate::ops::webhook::Change::Reconcile, + admission_deadline_ts: None, + }) + .await + { + Ok(ring) => Ok(Some( + crate::next_wake_ts(&ring.keys, crate::webhook::policy())? + .max(rivet_util::timestamp::now() + 1_000) + .min(rivet_util::timestamp::now() + 60_000), + )), + Err(_) => { + tracing::warn!(namespace_id = %input.namespace_id, "webhook key lifecycle reconciliation will retry"); + Ok(Some(rivet_util::timestamp::now() + 5_000)) + } + } +} diff --git a/engine/packages/auth-jwt/tests/grants.rs b/engine/packages/auth-jwt/tests/grants.rs index 25d0a62f84..c4ce404d59 100644 --- a/engine/packages/auth-jwt/tests/grants.rs +++ b/engine/packages/auth-jwt/tests/grants.rs @@ -146,6 +146,60 @@ fn canonicalizes_selector_order_and_merges_operations() { ); } +#[test] +fn typescript_canonical_grant_fixture_matches_rust() { + let namespace_id = Id::nil(); + let mut a = [0; 19]; + a[0] = 1; + a[1] = 1; + let mut b = [0; 19]; + b[0] = 1; + b[1] = 2; + let target_a = Id::from_slice(&a).unwrap(); + let target_b = Id::from_slice(&b).unwrap(); + let grants = [ + OwnedGrant { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Runner, + target: Scope::Any, + operations: vec![OperationKind::Delete], + }, + OwnedGrant { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Actor, + target: Scope::Id(target_b), + operations: vec![OperationKind::Create], + }, + OwnedGrant { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Actor, + target: Scope::Id(target_a), + operations: vec![ + OperationKind::List, + OperationKind::Read, + OperationKind::Read, + ], + }, + OwnedGrant { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Actor, + target: Scope::Any, + operations: vec![OperationKind::Update, OperationKind::Read], + }, + OwnedGrant { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Actor, + target: Scope::Id(target_a), + operations: vec![OperationKind::Update], + }, + ]; + + assert_eq!( + encode(namespace_id, &grants).unwrap(), + "AQAEAQACAAEBARMBAQAAAAAAAAAAAAAAAAAAAAAAAwABAgEBEwECAAAAAAAAAAAAAAAAAAAAAAABAwIAAQQ" + ); +} + #[test] fn rejects_operation_limit_before_normalization() { let namespace_id = Id::nil(); diff --git a/engine/packages/auth-jwt/tests/unit/application.rs b/engine/packages/auth-jwt/tests/unit/application.rs new file mode 100644 index 0000000000..f537a56064 --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/application.rs @@ -0,0 +1,110 @@ +use super::*; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde_json::json; + +fn claims() -> Claims { + Claims { + rivet_ver: 1, rivet_purpose: "durable-streams.callback".into(), iss: "https://api.rivet.dev".into(), + aud: "durable-streams".into(), sub: None, iat: 1000, exp: 87400, + jti: crate::TokenId::generate(&mut rand::rngs::OsRng).to_string(), + rivet_ns: crate::Id::new_v1(1).to_string(), + app: json!({"durable_streams":{"root":"/streams", "subscription_id":"sub-1","wake_id":"wake-1","generation":7,"operations":["callback"]}}).as_object().unwrap().clone(), + } +} + +#[test] +fn application_tokens_are_signed_but_never_engine_credentials() { + let key = SigningKey::generate(); + let claims = claims(); + let token = encode(&key, &claims, "rivet-api").unwrap(); + assert!(crate::is_reserved_token(&token)); + assert!(crate::peek_header(&token).is_err()); + assert!( + crate::decode( + &token, + &key.verification_key(), + crate::DecodeOptions { + issuer: &claims.iss, + audience: &claims.aud, + now: 1001 + } + ) + .is_err() + ); + let mut validation = jsonwebtoken::Validation::new(Algorithm::EdDSA); + validation.validate_exp = false; + validation.set_audience(&["durable-streams"]); + validation.set_issuer(&["https://api.rivet.dev"]); + let decoded = + jsonwebtoken::decode::(&token, &key.verification_key().decoding_key(), &validation) + .unwrap(); + assert_eq!(decoded.header.typ.as_deref(), Some(TOKEN_TYPE)); + assert_eq!(decoded.claims.app, claims.app); + assert!( + !String::from_utf8( + URL_SAFE_NO_PAD + .decode(token.split('.').nth(1).unwrap()) + .unwrap() + ) + .unwrap() + .contains("rivet_grants") + ); +} + +#[test] +fn application_profile_is_reserved_even_with_ambiguous_or_padded_headers() { + for header in [ + r#"{"typ":"rivet-app+jwt"}"#, + r#"{"typ":"JWT","typ":"rivet-app+jwt"}"#, + r#"{"typ":"rivet-app+jwt","typ":"JWT"}"#, + ] { + for encoded in [ + URL_SAFE_NO_PAD.encode(header), + base64::engine::general_purpose::URL_SAFE.encode(header), + ] { + assert!(crate::is_reserved_token(&format!( + "{encoded}.e30.signature" + ))); + } + } +} + +#[test] +fn bounds_and_reserved_fields_cannot_be_overridden() { + let key = SigningKey::generate(); + for audience in ["rivet-api", "custom-engine"] { + let mut c = claims(); + c.aud = audience.into(); + assert!(encode(&key, &c, "custom-engine").is_err()); + } + for purpose in ["", "RiVeT.auth", "engine-access"] { + let mut c = claims(); + c.rivet_purpose = purpose.into(); + assert!(encode(&key, &c, "rivet-api").is_err()); + } + for name in ["rivet_grants", "EXP", "engine", "invalid/name"] { + let mut c = claims(); + c.app.insert(name.into(), json!({})); + assert!(encode(&key, &c, "rivet-api").is_err()); + } + let mut c = claims(); + c.app + .insert("appdata".into(), json!({"data":"a".repeat(3072)})); + assert!(encode(&key, &c, "rivet-api").is_err()); + let mut deep = json!({}); + for _ in 0..8 { + deep = json!({"nested":deep}); + } + let mut c = claims(); + c.app.insert("nested".into(), deep); + assert!(encode(&key, &c, "rivet-api").is_err()); + let mut c = claims(); + c.exp = c.iat + PROTOCOL_MAX_TTL + 1; + assert!(encode(&key, &c, "rivet-api").is_err()); + let mut c = claims(); + c.app.insert( + "durable_streams".into(), + json!({"exp":0,"rivet_grants":"inert"}), + ); + assert!(encode(&key, &c, "rivet-api").is_ok()); +} diff --git a/engine/packages/auth-jwt/tests/unit/external_issuers.rs b/engine/packages/auth-jwt/tests/unit/external_issuers.rs new file mode 100644 index 0000000000..50b785b6d9 --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/external_issuers.rs @@ -0,0 +1,43 @@ +use super::*; +use rivet_auth_policy::{OperationKind, ResourceKind, Scope}; + +#[test] +fn registry_round_trip_preserves_namespace_bound() { + let namespace_id = Id::new_v1(1); + let registry = ExternalIssuerRegistry { + namespace_id, + generation: 1, + issuers: vec![ExternalIssuer { + name: "example".into(), + issuer: "https://issuer.example.com".into(), + audience: "rivet-api".into(), + jwks_url: "https://issuer.example.com/.well-known/jwks.json".into(), + algorithm: "EdDSA".into(), + grant_ceiling: vec![OwnedGrant { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Actor, + target: Scope::Any, + operations: vec![OperationKind::Read], + }], + }], + }; + let decoded = + ExternalIssuerRegistry::decode(namespace_id, ®istry.encode().unwrap()).unwrap(); + assert_eq!(decoded.generation, 1); + assert_eq!(decoded.issuers[0].grant_ceiling.len(), 1); + assert!( + ExternalIssuerRegistry::decode(Id::new_v1(2), ®istry.encode().unwrap()).is_err() + ); + for url in [ + "https://127.0.0.1/jwks.json", + "https://[::1]/jwks.json", + "https://issuer.example.com:8443/jwks.json", + "https://user:secret@issuer.example.com/jwks.json", + "https://issuer.example.com/jwks.json?key=secret", + "http://issuer.example.com/jwks.json", + ] { + let mut unsafe_registry = registry.clone(); + unsafe_registry.issuers[0].jwks_url = url.to_owned(); + assert!(unsafe_registry.validate().is_err(), "accepted {url}"); + } +} diff --git a/engine/packages/auth-jwt/tests/unit/jwks.rs b/engine/packages/auth-jwt/tests/unit/jwks.rs new file mode 100644 index 0000000000..282ad05676 --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/jwks.rs @@ -0,0 +1,43 @@ +use super::*; + +#[test] +fn publishes_only_live_public_keys() { + let key = |kid: &str, lifecycle| VerificationKeyEntry { + kid: kid.to_owned(), + public_key: vec![7; 32], + lifecycle, + }; + let keys = vec![ + key( + "pending", + VerificationKeyLifecycle::Pending { + activate_after_ts: 20, + }, + ), + key( + "active", + VerificationKeyLifecycle::Active { + activated_ts: 1, + sign_until_ts: 20, + }, + ), + key( + "expired", + VerificationKeyLifecycle::Retiring { + retired_ts: 1, + max_token_exp_ts: 8, + verify_until_ts: 10, + }, + ), + ]; + let jwks = JwkSet::from_verification_keys(&keys, 10); + assert_eq!(jwks.keys.len(), 2); + assert_eq!(jwks.keys[0].kid, "pending"); + assert_eq!(jwks.keys[1].kid, "active"); + let json = serde_json::to_value(jwks).unwrap(); + let published_key = &json["keys"][0]; + assert_eq!(published_key["use"], "sig"); + assert_eq!(published_key["kty"], "OKP"); + assert!(published_key.get("d").is_none()); + assert_eq!(published_key["x"], URL_SAFE_NO_PAD.encode([7; 32])); +} diff --git a/engine/packages/auth-jwt/tests/unit/key_ring_cache_external.rs b/engine/packages/auth-jwt/tests/unit/key_ring_cache_external.rs new file mode 100644 index 0000000000..b0c5277807 --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/key_ring_cache_external.rs @@ -0,0 +1,516 @@ +use super::*; +use rivet_auth_policy::{OperationKind, OwnedGrant, ResourceKind, Scope}; +use std::{ + collections::VecDeque, + sync::{ + Mutex as StdMutex, + atomic::{AtomicBool, AtomicI64, AtomicU8, AtomicUsize, Ordering}, + }, +}; +use tokio::sync::{Mutex, watch}; + +fn verifier_with_registry_source( + registry_fetcher: RegistryFetcher, + jwks_reads: Arc, +) -> Arc { + let (snapshot_tx, _) = watch::channel(None); + Arc::new(KeyRingCache { + mode: super::super::KeyRingCacheMode::VerifyOnly, + fetch_snapshot: Arc::new(|| Box::pin(async { bail!("unused snapshot source") })), + cache_ttl: CACHE_TTL, + max_stale: Duration::from_secs(60), + unknown_kid_cooldown: UNKNOWN_KID_COOLDOWN, + snapshot_tx, + refresh_lock: Mutex::new(()), + background_refresh_running: AtomicBool::new(false), + last_unknown_refresh_ts: AtomicI64::new(i64::MIN), + last_refresh_failure_ts: AtomicI64::new(i64::MIN), + fallback_state: AtomicU8::new(0), + external_fetch_registry: registry_fetcher, + external_fetch_jwks: Arc::new(move |_| { + jwks_reads.fetch_add(1, Ordering::Relaxed); + Box::pin(async { Ok(BTreeMap::new()) }) + }), + external_cache: Mutex::new(BTreeMap::new()), + }) +} + +fn registry( + namespace_id: Id, + generation: u64, + allowed_operations: Option>, +) -> ExternalIssuerRegistry { + ExternalIssuerRegistry { + namespace_id, + generation, + issuers: allowed_operations + .map(|operations| { + vec![crate::ExternalIssuer { + name: "example".into(), + issuer: "https://issuer.example.com".into(), + audience: "rivet-api".into(), + jwks_url: "https://issuer.example.com/.well-known/jwks.json".into(), + algorithm: "EdDSA".into(), + grant_ceiling: vec![OwnedGrant { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Actor, + target: Scope::Any, + operations, + }], + }] + }) + .unwrap_or_default(), + } +} + +#[tokio::test] +async fn failed_cold_refresh_is_single_flight_during_cooldown() { + let reads = Arc::new(AtomicUsize::new(0)); + let registry_fetcher: RegistryFetcher = { + let reads = Arc::clone(&reads); + Arc::new(move |_| { + let reads = Arc::clone(&reads); + Box::pin(async move { + reads.fetch_add(1, Ordering::Relaxed); + bail!("registry unavailable") + }) + }) + }; + let verifier = + verifier_with_registry_source(registry_fetcher, Arc::new(AtomicUsize::new(0))); + let cache_key = (Id::new_v1(1), "https://issuer.example.com".to_owned()); + let entry = verifier.external_entry(&cache_key).await.unwrap(); + let mut tasks = Vec::new(); + for _ in 0..8 { + let verifier = Arc::clone(&verifier); + let entry = Arc::clone(&entry); + let cache_key = cache_key.clone(); + tasks.push(tokio::spawn(async move { + let mut state = entry.state.lock().await; + verifier + .refresh_external_entry(&mut state, &cache_key) + .await + })); + } + for task in tasks { + assert!(matches!( + task.await.unwrap(), + Err(VerificationFailure::VerificationUnavailable) + )); + } + assert_eq!(reads.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn full_cold_cache_does_not_evict_in_flight_refreshes() { + let reads = Arc::new(AtomicUsize::new(0)); + let pending = Arc::new(tokio::sync::Semaphore::new(0)); + let (started_tx, mut started_rx) = tokio::sync::mpsc::unbounded_channel(); + let registry_fetcher: RegistryFetcher = { + let reads = Arc::clone(&reads); + let pending = Arc::clone(&pending); + Arc::new(move |_| { + let reads = Arc::clone(&reads); + let pending = Arc::clone(&pending); + let started_tx = started_tx.clone(); + Box::pin(async move { + reads.fetch_add(1, Ordering::Relaxed); + started_tx.send(()).unwrap(); + pending.acquire().await.unwrap().forget(); + bail!("registry unavailable") + }) + }) + }; + let verifier = + verifier_with_registry_source(registry_fetcher, Arc::new(AtomicUsize::new(0))); + let namespace_id = Id::new_v1(1); + let keys: Vec<_> = (0..MAX_COLD_ISSUERS) + .map(|index| (namespace_id, format!("https://unknown-{index}.example.com"))) + .collect(); + for key in &keys { + let entry = verifier.external_entry(key).await.unwrap(); + entry.state.lock().await.last_cold_result = + Some(Instant::now() - FAILED_REFRESH_COOLDOWN - Duration::from_secs(1)); + } + let mut tasks = Vec::new(); + for key in &keys { + let verifier = Arc::clone(&verifier); + let key = key.clone(); + tasks.push(tokio::spawn(async move { + let entry = verifier.external_entry(&key).await?; + let mut state = entry.state.lock().await; + verifier.refresh_external_entry(&mut state, &key).await + })); + } + for _ in &keys { + tokio::time::timeout(Duration::from_secs(5), started_rx.recv()) + .await + .unwrap() + .unwrap(); + } + let excess = (namespace_id, "https://excess.example.com".to_owned()); + assert!(matches!( + verifier.external_entry(&excess).await, + Err(VerificationFailure::VerificationUnavailable) + )); + let duplicate = { + let verifier = Arc::clone(&verifier); + let key = keys[0].clone(); + tokio::spawn(async move { + let entry = verifier.external_entry(&key).await?; + let mut state = entry.state.lock().await; + verifier.refresh_external_entry(&mut state, &key).await + }) + }; + assert_eq!(reads.load(Ordering::Relaxed), MAX_COLD_ISSUERS); + pending.add_permits(MAX_COLD_ISSUERS); + for task in tasks { + assert!(matches!( + task.await.unwrap(), + Err(VerificationFailure::VerificationUnavailable) + )); + } + assert!(matches!( + duplicate.await.unwrap(), + Err(VerificationFailure::VerificationUnavailable) + )); + assert_eq!(reads.load(Ordering::Relaxed), MAX_COLD_ISSUERS); +} + +#[tokio::test] +async fn cancelled_cold_refreshes_release_the_cold_budget() { + let pending = Arc::new(tokio::sync::Semaphore::new(0)); + let (started_tx, mut started_rx) = tokio::sync::mpsc::unbounded_channel(); + let registry_fetcher: RegistryFetcher = Arc::new(move |_| { + let pending = Arc::clone(&pending); + let started_tx = started_tx.clone(); + Box::pin(async move { + started_tx.send(()).unwrap(); + pending.acquire().await.unwrap().forget(); + bail!("registry unavailable") + }) + }); + let verifier = + verifier_with_registry_source(registry_fetcher, Arc::new(AtomicUsize::new(0))); + let namespace_id = Id::new_v1(1); + let mut tasks = Vec::new(); + for index in 0..MAX_COLD_ISSUERS { + let key = (namespace_id, format!("https://unknown-{index}.example.com")); + verifier.external_cache.lock().await.insert( + key.clone(), + Arc::new(ExternalIssuerEntry { + state: tokio::sync::Mutex::new(ExternalIssuerState::default()), + first_seen: Instant::now() - FAILED_REFRESH_COOLDOWN - Duration::from_secs(1), + verified: AtomicBool::new(false), + }), + ); + let verifier = Arc::clone(&verifier); + tasks.push(tokio::spawn(async move { + let entry = verifier.external_entry(&key).await?; + let mut state = entry.state.lock().await; + verifier.refresh_external_entry(&mut state, &key).await + })); + } + for _ in 0..MAX_COLD_ISSUERS { + tokio::time::timeout(Duration::from_secs(5), started_rx.recv()) + .await + .unwrap() + .unwrap(); + } + for task in tasks { + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + } + let new_key = (namespace_id, "https://new.example.com".to_owned()); + assert!(verifier.external_entry(&new_key).await.is_ok()); + assert_eq!(verifier.external_cache.lock().await.len(), MAX_COLD_ISSUERS); +} + +#[tokio::test] +async fn failed_warm_refresh_reuses_stale_keys_without_retrying_each_waiter() { + let reads = Arc::new(AtomicUsize::new(0)); + let registry_fetcher: RegistryFetcher = { + let reads = Arc::clone(&reads); + Arc::new(move |namespace_id| { + let reads = Arc::clone(&reads); + Box::pin(async move { + if reads.fetch_add(1, Ordering::Relaxed) == 0 { + Ok(ExternalIssuerRegistry::empty(namespace_id)) + } else { + bail!("registry unavailable") + } + }) + }) + }; + let verifier = + verifier_with_registry_source(registry_fetcher, Arc::new(AtomicUsize::new(0))); + let cache_key = (Id::new_v1(1), "https://issuer.example.com".to_owned()); + let entry = verifier.external_entry(&cache_key).await.unwrap(); + let mut state = entry.state.lock().await; + verifier + .refresh_external_entry(&mut state, &cache_key) + .await + .unwrap(); + state.cached.as_mut().unwrap().fetched_at = Instant::now() - Duration::from_secs(31); + verifier + .refresh_external_entry(&mut state, &cache_key) + .await + .unwrap(); + verifier + .refresh_external_entry(&mut state, &cache_key) + .await + .unwrap(); + assert_eq!(reads.load(Ordering::Relaxed), 2); +} + +#[tokio::test] +async fn unverified_hints_cannot_evict_registered_issuer() { + let registry_fetcher: RegistryFetcher = Arc::new(|namespace_id| { + Box::pin(async move { Ok(ExternalIssuerRegistry::empty(namespace_id)) }) + }); + let verifier = + verifier_with_registry_source(registry_fetcher, Arc::new(AtomicUsize::new(0))); + let namespace_id = Id::new_v1(1); + let registered_key = (namespace_id, "https://registered.example.com".to_owned()); + let registered = Arc::new(ExternalIssuerEntry::new()); + registered.verified.store(true, Ordering::Release); + verifier + .external_cache + .lock() + .await + .insert(registered_key.clone(), Arc::clone(®istered)); + for index in 0..MAX_COLD_ISSUERS { + let key = (namespace_id, format!("https://unknown-{index}.example.com")); + verifier.external_entry(&key).await.unwrap(); + } + let excess = (namespace_id, "https://excess.example.com".to_owned()); + assert!(matches!( + verifier.external_entry(&excess).await, + Err(VerificationFailure::VerificationUnavailable) + )); + assert!( + verifier + .external_cache + .lock() + .await + .get(®istered_key) + .is_some_and(|entry| Arc::ptr_eq(entry, ®istered)) + ); +} + +#[tokio::test] +async fn newly_verified_issuer_can_replace_a_full_positive_cache() { + let registry_fetcher: RegistryFetcher = Arc::new(|namespace_id| { + Box::pin(async move { Ok(ExternalIssuerRegistry::empty(namespace_id)) }) + }); + let verifier = + verifier_with_registry_source(registry_fetcher, Arc::new(AtomicUsize::new(0))); + let namespace_id = Id::new_v1(1); + for index in 0..MAX_CACHED_ISSUERS { + let entry = Arc::new(ExternalIssuerEntry::new()); + entry.verified.store(true, Ordering::Release); + verifier.external_cache.lock().await.insert( + ( + namespace_id, + format!("https://registered-{index}.example.com"), + ), + entry, + ); + } + let new_key = (namespace_id, "https://new.example.com".to_owned()); + let new_entry = verifier.external_entry(&new_key).await.unwrap(); + assert_eq!( + verifier.external_cache.lock().await.len(), + MAX_CACHED_ISSUERS + 1 + ); + new_entry.verified.store(true, Ordering::Release); + verifier.trim_verified_cache(&new_key, &new_entry).await; + let caches = verifier.external_cache.lock().await; + assert_eq!(caches.len(), MAX_CACHED_ISSUERS); + assert!( + caches + .get(&new_key) + .is_some_and(|entry| Arc::ptr_eq(entry, &new_entry)) + ); +} + +#[tokio::test] +async fn older_registry_never_restores_removed_or_reduced_issuer() { + let namespace_id = Id::new_v1(1); + let broad = vec![OperationKind::Read, OperationKind::Update]; + for narrowed in [None, Some(vec![OperationKind::Read])] { + let sequence = Arc::new(StdMutex::new(VecDeque::from([ + registry(namespace_id, 1, Some(broad.clone())), + registry(namespace_id, 2, narrowed.clone()), + registry(namespace_id, 1, Some(broad.clone())), + ]))); + let registry_fetcher: RegistryFetcher = Arc::new(move |_| { + let registry = sequence.lock().unwrap().pop_front().unwrap(); + Box::pin(async move { Ok(registry) }) + }); + let jwks_reads = Arc::new(AtomicUsize::new(0)); + let verifier = verifier_with_registry_source(registry_fetcher, jwks_reads.clone()); + let mut cached = None; + let cache_key = (namespace_id, "https://issuer.example.com".into()); + verifier + .refresh_external(&mut cached, &cache_key) + .await + .unwrap(); + verifier + .refresh_external(&mut cached, &cache_key) + .await + .unwrap(); + let fetched_at = cached.as_ref().unwrap().fetched_at; + let reads = jwks_reads.load(Ordering::Relaxed); + verifier + .refresh_external(&mut cached, &cache_key) + .await + .unwrap(); + let retained = cached.as_ref().unwrap(); + assert_eq!(retained.registry.generation, 2); + assert_eq!(retained.fetched_at, fetched_at); + assert_eq!(jwks_reads.load(Ordering::Relaxed), reads); + assert_eq!( + retained + .registry + .issuers + .first() + .map(|issuer| &issuer.grant_ceiling[0].operations), + narrowed.as_ref(), + ); + } +} + +#[tokio::test] +async fn registry_read_has_time_to_outlast_a_blackholed_epoxy_replica() { + let namespace_id = Id::new_v1(1); + let registry_fetcher: RegistryFetcher = Arc::new(move |_| { + Box::pin(async move { + tokio::time::sleep(Duration::from_secs(6)).await; + Ok(ExternalIssuerRegistry::empty(namespace_id)) + }) + }); + let verifier = + verifier_with_registry_source(registry_fetcher, Arc::new(AtomicUsize::new(0))); + let mut cached = None; + verifier + .refresh_external( + &mut cached, + &(namespace_id, "https://issuer.example.com".into()), + ) + .await + .unwrap(); + assert_eq!(cached.unwrap().registry.generation, 0); +} + +#[test] +fn jwks_parser_requires_unique_public_keys_and_pinned_algorithm() { + let x = URL_SAFE_NO_PAD.encode([7; 32]); + let public = serde_json::json!({"kty":"OKP", "crv":"Ed25519", "kid":"key-1", "alg":"EdDSA", "use":"sig", "x":x}); + let encoded = |keys: Vec| { + serde_json::to_vec(&serde_json::json!({"keys": keys})).unwrap() + }; + assert_eq!( + parse_jwks(&encoded(vec![public.clone()]), "EdDSA") + .unwrap() + .len(), + 1 + ); + let with_metadata = serde_json::to_vec(&serde_json::json!({ + "keys": [{ + "kty":"OKP", "crv":"Ed25519", "kid":"key-1", "alg":"EdDSA", + "use":"sig", "x":x, "vendor_metadata":"ignored" + }], + "issuer": "https://issuer.example.com" + })) + .unwrap(); + assert_eq!(parse_jwks(&with_metadata, "EdDSA").unwrap().len(), 1); + assert!(parse_jwks(&encoded(vec![]), "EdDSA").unwrap().is_empty()); + let other_algorithms = serde_json::json!({"kty":"RSA", "kid":"rsa-key", "alg":"RS256"}); + let encryption_key = serde_json::json!({ + "kty":"OKP", "crv":"Ed25519", "kid":"encryption-key", "use":"enc", "x":x + }); + assert_eq!( + parse_jwks( + &encoded(vec![public.clone(), other_algorithms, encryption_key]), + "EdDSA" + ) + .unwrap() + .len(), + 1 + ); + assert!( + parse_jwks( + &encoded(vec![serde_json::json!({"kty":"RSA", "alg":"RS256"})]), + "EdDSA" + ) + .unwrap() + .is_empty() + ); + assert!(parse_jwks(&encoded(vec![public.clone(), public.clone()]), "EdDSA").is_err()); + assert!( + parse_jwks(&encoded(vec![public.clone()]), "RS256") + .unwrap() + .is_empty() + ); + for component in ["d", "p", "q", "dp", "dq", "qi", "oth", "k"] { + let mut private = public.clone(); + private[component] = serde_json::json!(URL_SAFE_NO_PAD.encode([8; 32])); + assert!(parse_jwks(&encoded(vec![private]), "EdDSA").is_err()); + } + let mut wrong_use = public; + wrong_use["use"] = serde_json::json!("enc"); + assert!( + parse_jwks(&encoded(vec![wrong_use]), "EdDSA") + .unwrap() + .is_empty() + ); +} +#[test] +fn rejects_private_and_mapped_addresses() { + for ip in [ + "127.0.0.1", + "10.0.0.1", + "100.100.100.100", + "169.254.169.254", + "172.16.0.1", + "192.168.1.1", + "192.0.2.1", + "192.0.0.9", + "192.88.99.1", + "198.18.0.1", + "::1", + "fe80::1", + "fc00::1", + "2001:db8::1", + "::ffff:127.0.0.1", + ] { + assert!(!public_ip(ip.parse().unwrap()), "{ip} must be blocked"); + } + assert!(public_ip("8.8.8.8".parse().unwrap())); +} + +#[tokio::test] +async fn jwks_fetch_client_does_not_follow_redirects() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut connection, _) = listener.accept().await.unwrap(); + let mut request = [0; 1_024]; + connection.read(&mut request).await.unwrap(); + connection + .write_all(b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:9/private\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + }); + let client = jwks_client("127.0.0.1", &[address]).unwrap(); + let response = client + .get(format!("http://{address}/jwks.json")) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::FOUND); + server.await.unwrap(); +} diff --git a/engine/packages/auth-jwt/tests/unit/signing.rs b/engine/packages/auth-jwt/tests/unit/signing.rs index b350302272..b5d09edaa9 100644 --- a/engine/packages/auth-jwt/tests/unit/signing.rs +++ b/engine/packages/auth-jwt/tests/unit/signing.rs @@ -111,6 +111,11 @@ impl Source { last_unknown_refresh_ts: AtomicI64::new(i64::MIN), last_refresh_failure_ts: AtomicI64::new(i64::MIN), fallback_state: AtomicU8::new(0), + external_fetch_registry: Arc::new(|namespace_id| { + Box::pin(async move { Ok(crate::ExternalIssuerRegistry::empty(namespace_id)) }) + }), + external_fetch_jwks: Arc::new(|_| Box::pin(async { Ok(Default::default()) })), + external_cache: Mutex::new(Default::default()), }) } diff --git a/engine/packages/auth-jwt/tests/unit/signing_quota.rs b/engine/packages/auth-jwt/tests/unit/signing_quota.rs new file mode 100644 index 0000000000..2d460ecf66 --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/signing_quota.rs @@ -0,0 +1,42 @@ +use super::*; +fn state() -> Data { + Data { + window_start: 10_000, + application_operations: 0, + webhook_operations: 0, + webhook_bytes: 0, + leader_datacenter: 1, + leader_generation: 1, + } +} +fn limits() -> rivet_config::config::auth::SigningQuota { + rivet_config::config::auth::SigningQuota { + application_operations: 2, + webhook_operations: 1, + webhook_bytes: 1024, + } +} +#[test] +fn quota_restart_clock_rollback_reconfiguration_and_fencing() { + let mut s = state(); + admit(&mut s, Charge::Application, limits(), 10_000, 1, 1).unwrap(); + let bytes = AuthSigningQuotaData::wrap_latest(s) + .serialize_with_embedded_version(rivet_data::generated::AUTH_SIGNING_QUOTA_VERSION) + .unwrap(); + let mut s = AuthSigningQuotaData::deserialize_with_embedded_version(&bytes).unwrap(); + assert_eq!(s.application_operations, 1); + admit(&mut s, Charge::Application, limits(), 9_000, 1, 1).unwrap(); + assert!(admit(&mut s, Charge::Application, limits(), 9_000, 1, 1).is_err()); + let mut lower = limits(); + lower.application_operations = 1; + assert!(admit(&mut s, Charge::Application, lower, 10_000, 1, 1).is_err()); + assert!(admit(&mut s, Charge::Webhook, limits(), 10_000, 2, 1).is_err()); + admit(&mut s, Charge::Webhook, limits(), 10_000, 2, 2).unwrap(); + assert!(admit(&mut s, Charge::Webhook, limits(), 10_000, 1, 1).is_err()); + assert!(admit(&mut s, Charge::Webhook, limits(), 10_000, 2, 2).is_err()); + admit(&mut s, Charge::Bytes(1000), limits(), 10_000, 2, 2).unwrap(); + assert!(admit(&mut s, Charge::Bytes(25), limits(), 10_000, 2, 2).is_err()); + admit(&mut s, Charge::Application, lower, 70_000, 2, 2).unwrap(); + assert_eq!(s.webhook_operations, 0); + assert_eq!(s.webhook_bytes, 0); +} diff --git a/engine/packages/auth-jwt/tests/unit/verifier.rs b/engine/packages/auth-jwt/tests/unit/verifier.rs index d9ad7670bf..7b358cb4e5 100644 --- a/engine/packages/auth-jwt/tests/unit/verifier.rs +++ b/engine/packages/auth-jwt/tests/unit/verifier.rs @@ -1,11 +1,16 @@ use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, AtomicUsize, Ordering}; +use std::time::Instant; use anyhow::Context; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use rivet_auth_policy::{OperationKind, OwnedGrant, ResourceKind, Scope}; use tokio::sync::Notify; use super::*; -use crate::{Claims, Id, KeyId, SigningKey, TokenId, ValidatedGrantSet, encode, encode_grants}; +use crate::{ + Claims, ExternalIssuer, ExternalIssuerRegistry, Id, KeyId, SigningKey, TokenId, + ValidatedGrantSet, encode, encode_grants, +}; #[derive(Clone)] struct MockSource { @@ -122,6 +127,11 @@ fn test_verifier( last_unknown_refresh_ts: AtomicI64::new(i64::MIN), last_refresh_failure_ts: AtomicI64::new(i64::MIN), fallback_state: AtomicU8::new(0), + external_fetch_registry: Arc::new(|namespace_id| { + Box::pin(async move { Ok(crate::ExternalIssuerRegistry::empty(namespace_id)) }) + }), + external_fetch_jwks: Arc::new(|_| Box::pin(async move { Ok(BTreeMap::new()) })), + external_cache: Mutex::new(BTreeMap::new()), }) } @@ -204,6 +214,405 @@ fn signed_token(signing_key: &SigningKey, issuer: &str, now: u64) -> String { .unwrap() } +#[tokio::test] +async fn registered_jwks_issuer_cannot_exceed_its_namespace_grant_ceiling() { + let namespace_id = Id::new_v1(1); + let signing_key = SigningKey::from_seed(KeyId::from_bytes([7; 16]), [42; 32]); + let grant = OwnedGrant { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Actor, + target: Scope::Any, + operations: vec![OperationKind::Read], + }; + let issuer = ExternalIssuer { + name: "example".into(), + issuer: "https://issuer.example.com".into(), + audience: "rivet-api".into(), + jwks_url: "https://issuer.example.com/.well-known/jwks.json".into(), + algorithm: "EdDSA".into(), + grant_ceiling: vec![grant.clone()], + }; + let registry = Arc::new(Mutex::new(ExternalIssuerRegistry { + namespace_id, + generation: 1, + issuers: vec![issuer.clone()], + })); + let fetches = Arc::new(AtomicUsize::new(0)); + let fail_registry = Arc::new(AtomicBool::new(false)); + let empty_jwks = Arc::new(AtomicBool::new(false)); + let source = MockSource::new(Some(snapshot(1, &[3]))); + let mut verifier = test_verifier( + &source, + Duration::from_secs(60), + Duration::from_secs(300), + Duration::from_secs(5), + ); + Arc::get_mut(&mut verifier).unwrap().external_fetch_registry = { + let registry = Arc::clone(®istry); + let fetches = Arc::clone(&fetches); + let fail_registry = Arc::clone(&fail_registry); + Arc::new(move |requested_namespace| { + let registry = Arc::clone(®istry); + let fetches = Arc::clone(&fetches); + let fail_registry = Arc::clone(&fail_registry); + Box::pin(async move { + fetches.fetch_add(1, Ordering::Relaxed); + if fail_registry.load(Ordering::Acquire) { + anyhow::bail!("all registry replicas are unavailable"); + } + if requested_namespace == namespace_id { + Ok(registry.lock().await.clone()) + } else { + Ok(ExternalIssuerRegistry::empty(requested_namespace)) + } + }) + }) + }; + Arc::get_mut(&mut verifier).unwrap().external_fetch_jwks = { + let public_key = signing_key.public_key(); + let kid = signing_key.kid().to_string(); + let empty_jwks = Arc::clone(&empty_jwks); + Arc::new(move |_| { + let kid = kid.clone(); + let empty_jwks = Arc::clone(&empty_jwks); + Box::pin(async move { + if empty_jwks.load(Ordering::Acquire) { + return Ok(BTreeMap::new()); + } + Ok(BTreeMap::from([( + kid, + jsonwebtoken::DecodingKey::from_ed_der(&public_key), + )])) + }) + }) + }; + verifier.refresh(false).await.unwrap(); + let now = u64::try_from(rivet_util::timestamp::now() / 1_000).unwrap(); + let token_for = + |token_namespace: Id, token_issuer: &str, grant: OwnedGrant, audience: &str| { + let grants = ValidatedGrantSet::new(token_namespace, [grant]).unwrap(); + encode( + &signing_key, + &Claims { + rivet_ver: crate::CLAIMS_VERSION, + iss: token_issuer.into(), + aud: audience.into(), + sub: Some("user-123".into()), + iat: now, + exp: now + 60, + jti: TokenId::from_bytes([9; 16]).to_string(), + rivet_ns: token_namespace.to_string(), + rivet_grants: encode_grants(&grants).unwrap(), + }, + ) + .unwrap() + }; + let token = |grant: OwnedGrant, audience: &str| { + token_for(namespace_id, &issuer.issuer, grant, audience) + }; + assert!( + verifier + .verify(&token(grant.clone(), "rivet-api")) + .await + .is_ok() + ); + assert!( + verifier + .verify(&token(grant.clone(), "rivet-api")) + .await + .is_ok() + ); + assert_eq!( + fetches.load(Ordering::Relaxed), + 1, + "warm JWT requests must not read Epoxy" + ); + let unknown_signer = SigningKey::from_seed(KeyId::from_bytes([10; 16]), [44; 32]); + let unknown_grants = ValidatedGrantSet::new(namespace_id, [grant.clone()]).unwrap(); + let unknown_token = encode( + &unknown_signer, + &Claims { + rivet_ver: crate::CLAIMS_VERSION, + iss: issuer.issuer.clone(), + aud: "rivet-api".into(), + sub: Some("user-123".into()), + iat: now, + exp: now + 60, + jti: TokenId::from_bytes([10; 16]).to_string(), + rivet_ns: namespace_id.to_string(), + rivet_grants: encode_grants(&unknown_grants).unwrap(), + }, + ) + .unwrap(); + assert!(matches!( + verifier.verify(&unknown_token).await, + Err(VerificationFailure::InvalidToken) + )); + assert!(matches!( + verifier.verify(&unknown_token).await, + Err(VerificationFailure::InvalidToken) + )); + assert_eq!( + fetches.load(Ordering::Relaxed), + 2, + "unknown kid refresh must be rate limited" + ); + assert!(matches!( + verifier + .verify(&token(grant.clone(), "another-service")) + .await, + Err(VerificationFailure::InvalidToken) + )); + assert!(matches!( + verifier + .verify(&token_for( + namespace_id, + "https://unregistered.example.com", + grant.clone(), + "rivet-api", + )) + .await, + Err(VerificationFailure::InvalidToken) + )); + let other_namespace = Id::new_v1(2); + let mut other_namespace_grant = grant.clone(); + other_namespace_grant.namespace = Scope::Id(other_namespace); + assert!(matches!( + verifier + .verify(&token_for( + other_namespace, + &issuer.issuer, + other_namespace_grant, + "rivet-api", + )) + .await, + Err(VerificationFailure::InvalidToken) + )); + let valid = token(grant.clone(), "rivet-api"); + let different = token(grant.clone(), "another-service"); + let tampered = format!( + "{}.{}", + valid.rsplit_once('.').unwrap().0, + different.rsplit_once('.').unwrap().1, + ); + assert!(matches!( + verifier.verify(&tampered).await, + Err(VerificationFailure::InvalidToken) + )); + let (_, signed_payload) = valid.split_once('.').unwrap(); + let (payload, _) = signed_payload.split_once('.').unwrap(); + let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap(); + let mut wrong_algorithm_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256); + wrong_algorithm_header.kid = Some(signing_key.kid().to_string()); + wrong_algorithm_header.typ = Some(crate::TOKEN_TYPE.into()); + let public_key = signing_key.public_key(); + let wrong_algorithm = jsonwebtoken::encode( + &wrong_algorithm_header, + &claims, + &jsonwebtoken::EncodingKey::from_secret(&public_key), + ) + .unwrap(); + let mut hs_validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); + hs_validation.required_spec_claims.clear(); + hs_validation.validate_aud = false; + assert!( + jsonwebtoken::decode::( + &wrong_algorithm, + &jsonwebtoken::DecodingKey::from_secret(&public_key), + &hs_validation, + ) + .is_ok(), + "forged token must have a valid HS256 signature" + ); + assert!(matches!( + verifier.verify(&wrong_algorithm).await, + Err(VerificationFailure::InvalidToken) + )); + let mut excessive = grant.clone(); + excessive.operations.push(OperationKind::Delete); + assert!(matches!( + verifier.verify(&token(excessive, "rivet-api")).await, + Err(VerificationFailure::InvalidToken) + )); + let issuer_cache = verifier + .external_cache + .lock() + .await + .get(&(namespace_id, issuer.issuer.clone())) + .unwrap() + .clone(); + fail_registry.store(true, Ordering::Release); + issuer_cache + .state + .lock() + .await + .cached + .as_mut() + .unwrap() + .fetched_at = Instant::now() - Duration::from_secs(31); + assert!( + verifier + .verify(&token(grant.clone(), "rivet-api")) + .await + .is_ok() + ); + issuer_cache + .state + .lock() + .await + .cached + .as_mut() + .unwrap() + .fetched_at = Instant::now() - Duration::from_secs(301); + assert!(matches!( + verifier.verify(&token(grant.clone(), "rivet-api")).await, + Err(VerificationFailure::VerificationUnavailable) + )); + fail_registry.store(false, Ordering::Release); + empty_jwks.store(true, Ordering::Release); + { + let mut state = issuer_cache.state.lock().await; + // The service retries after the failed-refresh cooldown, not on every waiter. + state.last_failed_refresh = Some(Instant::now() - Duration::from_secs(6)); + state.cached.as_mut().unwrap().fetched_at = Instant::now() - Duration::from_secs(31); + } + assert!(matches!( + verifier.verify(&token(grant.clone(), "rivet-api")).await, + Err(VerificationFailure::InvalidToken) + )); + empty_jwks.store(false, Ordering::Release); + issuer_cache + .state + .lock() + .await + .cached + .as_mut() + .unwrap() + .fetched_at = Instant::now() - Duration::from_secs(31); + assert!( + verifier + .verify(&token(grant.clone(), "rivet-api")) + .await + .is_ok() + ); + registry.lock().await.issuers.clear(); + issuer_cache + .state + .lock() + .await + .cached + .as_mut() + .unwrap() + .fetched_at = Instant::now() - Duration::from_secs(31); + assert!(matches!( + verifier + .verify(&token(issuer.grant_ceiling[0].clone(), "rivet-api")) + .await, + Err(VerificationFailure::InvalidToken) + )); +} + +#[tokio::test] +async fn stalled_jwks_issuer_does_not_block_another_cached_issuer() { + let namespace_id = Id::new_v1(2); + let signing_key = SigningKey::from_seed(KeyId::from_bytes([8; 16]), [43; 32]); + let grant = OwnedGrant { + namespace: Scope::Id(namespace_id), + resource: ResourceKind::Actor, + target: Scope::Any, + operations: vec![OperationKind::Read], + }; + let issuer = |name: &str| ExternalIssuer { + name: name.into(), + issuer: format!("https://{name}.example.com"), + audience: "rivet-api".into(), + jwks_url: format!("https://{name}.example.com/.well-known/jwks.json"), + algorithm: "EdDSA".into(), + grant_ceiling: vec![grant.clone()], + }; + let registry = ExternalIssuerRegistry { + namespace_id, + generation: 1, + issuers: vec![issuer("fast"), issuer("slow")], + }; + let source = MockSource::new(Some(snapshot(1, &[3]))); + let mut verifier = test_verifier( + &source, + Duration::from_secs(60), + Duration::from_secs(300), + Duration::from_secs(5), + ); + Arc::get_mut(&mut verifier).unwrap().external_fetch_registry = Arc::new(move |_| { + let registry = registry.clone(); + Box::pin(async move { Ok(registry) }) + }); + let slow_started = Arc::new(AtomicBool::new(false)); + let release_slow = Arc::new(Notify::new()); + Arc::get_mut(&mut verifier).unwrap().external_fetch_jwks = { + let slow_started = Arc::clone(&slow_started); + let release_slow = Arc::clone(&release_slow); + let public_key = signing_key.public_key(); + let kid = signing_key.kid().to_string(); + Arc::new(move |issuer| { + let slow_started = Arc::clone(&slow_started); + let release_slow = Arc::clone(&release_slow); + let kid = kid.clone(); + Box::pin(async move { + if issuer.name == "slow" { + slow_started.store(true, Ordering::Release); + release_slow.notified().await; + } + Ok(BTreeMap::from([( + kid, + jsonwebtoken::DecodingKey::from_ed_der(&public_key), + )])) + }) + }) + }; + verifier.refresh(false).await.unwrap(); + let now = u64::try_from(rivet_util::timestamp::now() / 1_000).unwrap(); + let token = |issuer: &str| { + let grants = ValidatedGrantSet::new(namespace_id, [grant.clone()]).unwrap(); + encode( + &signing_key, + &Claims { + rivet_ver: crate::CLAIMS_VERSION, + iss: format!("https://{issuer}.example.com"), + aud: "rivet-api".into(), + sub: Some("user-123".into()), + iat: now, + exp: now + 60, + jti: TokenId::from_bytes([9; 16]).to_string(), + rivet_ns: namespace_id.to_string(), + rivet_grants: encode_grants(&grants).unwrap(), + }, + ) + .unwrap() + }; + assert!(verifier.verify(&token("fast")).await.is_ok()); + let slow = tokio::spawn({ + let verifier = Arc::clone(&verifier); + let token = token("slow"); + async move { verifier.verify(&token).await } + }); + let started_deadline = Instant::now() + Duration::from_secs(1); + while !slow_started.load(Ordering::Acquire) { + assert!( + Instant::now() < started_deadline, + "slow fetch did not start" + ); + tokio::task::yield_now().await; + } + assert!( + tokio::time::timeout(Duration::from_secs(1), verifier.verify(&token("fast"))) + .await + .expect("stalled issuer blocked the fast issuer") + .is_ok() + ); + release_slow.notify_one(); + assert!(slow.await.unwrap().is_ok()); +} + #[test] fn durable_snapshot_accepts_retiring_issuer_only_until_deadline() { let now = 1_788_138_000; diff --git a/engine/packages/auth-jwt/tests/unit/webhook.rs b/engine/packages/auth-jwt/tests/unit/webhook.rs new file mode 100644 index 0000000000..44b27ca26c --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/webhook.rs @@ -0,0 +1,159 @@ +use super::*; + +#[test] +fn namespace_rings_persist_without_sharing_authentication_keys() { + let ns = Id::new_v1(1); + let ring = Ring::bootstrap(ns, 1, 1, 1000).unwrap(); + let bytes = ring.encode().unwrap(); + assert_eq!(Ring::decode(ns, &bytes).unwrap(), ring); + assert!(Ring::decode(Id::new_v1(1), &bytes).is_err()); + let other = Ring::bootstrap(Id::new_v1(1), 1, 1, 1000).unwrap(); + assert_ne!( + ring.keys.active.key.public_key, + other.keys.active.key.public_key + ); + assert!(public_kid(&ring.keys.active.key.public_key).starts_with("ds_")); + let jwks = serde_json::to_value(ring.public_keys(1000)).unwrap(); + assert_eq!(jwks[0].as_object().unwrap().len(), 6); + assert!(!jwks.to_string().contains("seed")); +} + +#[test] +fn normal_rotation_publishes_before_use_and_retires_after_full_window() { + let ring = Ring::bootstrap(Id::new_v1(1), 1, 1, 0).unwrap(); + let old = public_kid(&ring.keys.active.key.public_key); + let stage = policy().rotation_interval_ms - policy().publish_lead_ms; + let pending = ring.reconcile(1, 1, stage).unwrap().unwrap(); + assert_eq!(pending.public_keys(stage).len(), 2); + assert_eq!(public_kid(&pending.keys.active.key.public_key), old); + assert!(pending.reconcile(1, 1, stage + 1).unwrap().is_none()); + let active = pending + .reconcile(1, 1, policy().rotation_interval_ms) + .unwrap() + .unwrap(); + assert_ne!(public_kid(&active.keys.active.key.public_key), old); + let deadline = policy().rotation_interval_ms + 365_000; + assert_eq!(active.keys.retiring[0].verify_until_ts, deadline); + assert!( + active + .public_keys(deadline - 1) + .iter() + .any(|key| key.kid == old) + ); + assert!( + !active + .public_keys(deadline) + .iter() + .any(|key| key.kid == old) + ); +} + +#[test] +fn emergency_receipt_retries_compare_original_external_ids_before_resolving_removed_keys() { + let ring = Ring::bootstrap(Id::new_v1(1), 1, 1, 0).unwrap(); + let old = public_kid(&ring.keys.active.key.public_key); + let revoked = ring.revoke([1; 16], 1, &[old.clone()], 10).unwrap(); + assert!(!revoked.public_keys(10).iter().any(|key| key.kid == old)); + let restored = Ring::decode(ring.namespace_id, &revoked.encode().unwrap()).unwrap(); + assert_eq!(restored.revoke([1; 16], 1, &[old], 20).unwrap(), revoked); + assert!( + restored + .revoke( + [1; 16], + 1, + &[public_kid(&restored.keys.active.key.public_key)], + 20 + ) + .is_err() + ); + assert!(restored.rotate(1, 20).is_err()); + assert!( + restored + .revoke([2; 16], 2, &["unknown".into()], 20) + .is_err() + ); +} + +#[test] +fn namespace_lifecycle_fences_obsolete_topology() { + let ring = Ring::bootstrap(Id::new_v1(1), 1, 2, 0).unwrap(); + assert!(ring.reconcile(1, 1, 1).is_err()); + assert!(ring.reconcile(2, 2, 1).is_err()); + let config = ring.reconcile(2, 3, 1).unwrap().unwrap(); + let moved = config.reconcile(2, 3, 2).unwrap().unwrap(); + assert_eq!(moved.keys.leader_datacenter_id, 2); + assert!(moved.reconcile(1, 2, 3).is_err()); +} + +#[test] +fn signature_covers_the_timestamp_and_exact_binary_body() { + use ed25519_dalek::{Signature as DalekSignature, Verifier, VerifyingKey}; + let ring = Ring::bootstrap(Id::new_v1(1), 1, 1, 1_699_999_999_000).unwrap(); + let body = [0, 255, b'\r', b'\n', b'{', b'}']; + let signature = sign(&ring, &body, 1_700_000_000_999).unwrap(); + assert_eq!(signature.timestamp, "1700000000"); + assert!(!signature.signature.contains('=')); + let decoded = URL_SAFE_NO_PAD.decode(&signature.signature).unwrap(); + assert_eq!(decoded.len(), 64); + let signature = DalekSignature::from_slice(&decoded).unwrap(); + let key = VerifyingKey::from_bytes(&ring.keys.active.key.public_key).unwrap(); + let mut message = b"1700000000.".to_vec(); + message.extend_from_slice(&body); + key.verify(&message, &signature).unwrap(); + message[0] = b'2'; + assert!(key.verify(&message, &signature).is_err()); + message[0] = b'1'; + message.pop(); + assert!(key.verify(&message, &signature).is_err()); + assert!(sign(&ring, b"", 1_700_000_000_000).is_ok()); + assert!(sign(&ring, b"", ring.keys.active.sign_until_ts).is_err()); +} + +#[test] +fn ds_signature_and_thumbprint_match_independent_node_crypto_vector() { + let mut ring = Ring::bootstrap(Id::new_v1(1), 1, 1, 1_699_999_999_000).unwrap(); + let seed = std::array::from_fn(|i| i as u8); + let signing_key = crate::SigningKey::from_seed(KeyId::from_bytes([9; 16]), seed); + ring.keys.active.key.public.kid = signing_key.kid(); + ring.keys.active.key.public.public_key = signing_key.public_key(); + ring.keys.active.key.signing_key = signing_key; + let signature = sign(&ring, &[0, 255, 13, 10, 123, 125], 1_700_000_000_000).unwrap(); + assert_eq!( + signature.kid, + "ds_1IG2tMH7J2wbJZnOf8LJzQitKf7LMvoAElsuDMVM54Y" + ); + assert_eq!( + signature.signature, + "bSaq685LYN-Z-YQr_ek2LGPvIUveIhxOAb1Fayki66s_MV_yoMjVEQEFl542ZTRr2-BCf_6S4QhC4RmoTwWKBg" + ); +} + +#[test] +fn payload_limit_accepts_4096_canonicalized_maximum_path_snapshots() { + use ed25519_dalek::{Signature as DalekSignature, Verifier, VerifyingKey}; + let streams: Vec<_> = (0..4096).map(|i| { + let raw = format!("{i:04}/{}", "é".repeat(1020)); + assert!(raw.len() <= 2048); + let canonical = url::Url::parse(&format!("https://streams.gameinc.io/{raw}")).unwrap().path().trim_start_matches('/').to_owned(); + serde_json::json!({"path":canonical,"link_type":"explicit","acked_offset":"0000000000000001_0000000000000042","tail_offset":"0000000000000002_0000000000000084","has_pending":true}) + }).collect(); + let body = serde_json::to_vec(&serde_json::json!({"subscription_id":"sub-1","wake_id":"wake-1","generation":7,"streams":streams,"callback_url":"https://streams.gameinc.io/__ds/subscriptions/sub-1/callback","callback_token":"a".repeat(crate::MAX_TOKEN_BYTES)})).unwrap(); + assert!(body.len() > 24 * 1024 * 1024); + assert!( + body.len() < 32 * 1024 * 1024, + "4096-stream fixture must fit the default cap" + ); + let ring = Ring::bootstrap(Id::new_v1(1), 1, 1, 0).unwrap(); + let signature = sign(&ring, &body, 1000).unwrap(); + let mut message = b"1.".to_vec(); + message.extend_from_slice(&body); + VerifyingKey::from_bytes(&ring.keys.active.key.public_key) + .unwrap() + .verify( + &message, + &DalekSignature::from_slice(&URL_SAFE_NO_PAD.decode(signature.signature).unwrap()) + .unwrap(), + ) + .unwrap(); + assert!(sign(&ring, &vec![0; MAX_PAYLOAD_BYTES + 1], 1000).is_err()); +} diff --git a/engine/packages/auth-jwt/tests/unit/webhook_admission.rs b/engine/packages/auth-jwt/tests/unit/webhook_admission.rs new file mode 100644 index 0000000000..5322c3c088 --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/webhook_admission.rs @@ -0,0 +1,10 @@ +use super::mutate::check_admission; + +#[test] +fn webhook_management_admission_expires_before_a_late_proposal_or_retry() { + assert!(check_admission(Some(12_000), 11_999).is_ok()); + assert!(check_admission(Some(12_000), 12_000).is_err()); + assert!(check_admission(Some(12_000), 15_000).is_err()); + // Lifecycle reconciliation is independent of a caller credential. + assert!(check_admission(None, i64::MAX).is_ok()); +} diff --git a/engine/packages/auth-jwt/tests/unit/webhook_recovery.rs b/engine/packages/auth-jwt/tests/unit/webhook_recovery.rs new file mode 100644 index 0000000000..557f8f00cf --- /dev/null +++ b/engine/packages/auth-jwt/tests/unit/webhook_recovery.rs @@ -0,0 +1,125 @@ +use super::*; +use crate::webhook::Ring; +use std::{cell::RefCell, collections::BTreeMap}; + +fn recovery_page( + namespace_ids: Vec, + next: Option>, +) -> namespace::ops::signing_recovery_page::Output { + namespace::ops::signing_recovery_page::Output { + namespace_ids, + next, + } +} + +#[tokio::test] +async fn corrupt_ring_does_not_block_healthy_ring_or_advance_into_provisioning() { + let corrupt = Id::new_v1(1); + let healthy = Id::new_v1(1); + let absent = Id::new_v1(1); + let records = BTreeMap::from([ + (corrupt, vec![255, 255]), + ( + healthy, + Ring::bootstrap(healthy, 1, 1, 0).unwrap().encode().unwrap(), + ), + ]); + let original = records.clone(); + let dispatched = RefCell::new(Vec::new()); + let next = vec![1, 2, 3]; + let cursor = recover_page( + recovery_page(vec![corrupt, healthy, absent], Some(next.clone())), + |namespace_id| { + let records = &records; + async move { + records + .get(&namespace_id) + .map(|bytes| Ring::decode(namespace_id, bytes).map(|_| true)) + .unwrap_or(Ok(false)) + } + }, + |namespace_id| { + dispatched.borrow_mut().push(namespace_id); + async { Ok(()) } + }, + ) + .await; + assert_eq!(cursor, Some(next)); + assert_eq!(*dispatched.borrow(), vec![healthy]); + assert_eq!( + records, original, + "recovery must never overwrite corrupt rings or provision absent ones" + ); +} + +#[tokio::test] +async fn dispatch_failure_does_not_block_later_namespaces_and_is_retried_next_sweep() { + let first = Id::new_v1(1); + let second = Id::new_v1(1); + let dispatched = RefCell::new(Vec::new()); + let attempts = RefCell::new(0); + for _ in 0..2 { + let cursor = recover_page( + recovery_page(vec![first, second], None), + |_| async { Ok(true) }, + |namespace_id| { + let fail = namespace_id == first && *attempts.borrow() == 0; + if namespace_id == first { + *attempts.borrow_mut() += 1; + } + if !fail { + dispatched.borrow_mut().push(namespace_id); + } + async move { + if fail { + anyhow::bail!("simulated dispatch unavailable"); + } + Ok(()) + } + }, + ) + .await; + assert_eq!( + cursor, None, + "failed dispatch must not prevent completing the sweep" + ); + } + assert_eq!(*dispatched.borrow(), vec![second, first, second]); + assert_eq!(*attempts.borrow(), 2); +} + +#[tokio::test] +async fn idle_ring_is_selected_for_recovery_after_authority_changes() { + let namespace_id = Id::new_v1(1); + let persisted = Ring::bootstrap(namespace_id, 1, 1, 0) + .unwrap() + .encode() + .unwrap(); + let recovered = RefCell::new(None); + let now = crate::webhook::policy().rotation_interval_ms; + recover_page( + recovery_page(vec![namespace_id], None), + |id| std::future::ready(Ring::decode(id, &persisted).map(|_| true)), + |id| { + // Run the same planner used by the redispatched lifecycle worker, with no API traffic. + let mut ring = Ring::decode(id, &persisted).unwrap(); + while let Some(next) = ring.reconcile(2, 2, now).unwrap() { + ring = next; + } + recovered.replace(Some(ring)); + async { Ok(()) } + }, + ) + .await; + let recovered = recovered.into_inner().unwrap(); + assert_eq!(recovered.keys.leader_datacenter_id, 2); + assert_eq!(recovered.keys.leader_config_generation, 2); + assert!( + recovered.keys.pending.is_some(), + "idle rotation must publish a pending key after authority recovery" + ); + assert!( + recovered.reconcile(1, 1, now).is_err(), + "old authority must remain fenced" + ); +} diff --git a/engine/packages/auth-policy/src/authorize.rs b/engine/packages/auth-policy/src/authorize.rs index 2c4652a21c..2712a2dd17 100644 --- a/engine/packages/auth-policy/src/authorize.rs +++ b/engine/packages/auth-policy/src/authorize.rs @@ -1,4 +1,35 @@ -use crate::{AccessRequest, Grant, OwnedGrant, Scope}; +use crate::{AccessRequest, Grant, OperationKind, OwnedGrant, ResourceKind, Scope}; + +/// Signing endpoints have no caller-selected namespace. Only concrete grants for the +/// requested operation can select it; unrelated and wildcard grants do not select one. +pub fn signing_namespace( + grants: &[OwnedGrant], + resource: ResourceKind, +) -> Option { + if !matches!( + resource, + ResourceKind::ApplicationToken | ResourceKind::WebhookSigning + ) { + return None; + } + let mut namespace = None; + for grant in grants { + if grant.resource != resource + || !grant.operations.contains(&OperationKind::Create) + || !matches!(grant.target, Scope::Any) + { + continue; + } + let Scope::Id(candidate) = grant.namespace else { + continue; + }; + if namespace.is_some_and(|existing| existing != candidate) { + return None; + } + namespace = Some(candidate); + } + namespace +} /// `Scope::Any` widens only the grant side of the comparison. pub fn grant_allows( diff --git a/engine/packages/auth-policy/src/errors.rs b/engine/packages/auth-policy/src/errors.rs index 776263b89a..2ce4bb6955 100644 --- a/engine/packages/auth-policy/src/errors.rs +++ b/engine/packages/auth-policy/src/errors.rs @@ -35,3 +35,29 @@ pub enum Auth { )] IssuanceDisabled, } + +#[derive(RivetError, Debug, Deserialize, Serialize)] +#[error("signing")] +pub enum Signing { + #[error("invalid_input", "Invalid signing request.")] + InvalidInput, + #[error( + "payload_too_large", + "The signing payload or token exceeds its size limit." + )] + PayloadTooLarge, + #[error("unavailable", "The signing service is temporarily unavailable.")] + Unavailable, + #[error( + "throttled", + "The namespace signing quota or service capacity is exhausted." + )] + Throttled, + #[error( + "conflict", + "The signing key generation or request ID conflicts with current state." + )] + Conflict, + #[error("not_found", "The namespace signing keys have not been provisioned.")] + NotFound, +} diff --git a/engine/packages/auth-policy/src/lib.rs b/engine/packages/auth-policy/src/lib.rs index 59a4be1959..e8336dd07c 100644 --- a/engine/packages/auth-policy/src/lib.rs +++ b/engine/packages/auth-policy/src/lib.rs @@ -4,7 +4,7 @@ mod authorize; pub mod errors; mod types; -pub use authorize::{can_delegate, grant_allows, is_authorized}; +pub use authorize::{can_delegate, grant_allows, is_authorized, signing_namespace}; pub use types::{ AccessRequest, EffectiveAuthority, Grant, OperationKind, OwnedGrant, ResourceKind, Scope, }; diff --git a/engine/packages/auth-policy/src/types.rs b/engine/packages/auth-policy/src/types.rs index 25b92a7e24..df4002209e 100644 --- a/engine/packages/auth-policy/src/types.rs +++ b/engine/packages/auth-policy/src/types.rs @@ -32,6 +32,8 @@ pub enum ResourceKind { ActorGateway, ActorKv, Jwt, + ApplicationToken, + WebhookSigning, } impl fmt::Display for ResourceKind { @@ -41,6 +43,8 @@ impl fmt::Display for ResourceKind { ResourceKind::ActorGateway => "actor gateways", ResourceKind::ActorKv => "actor KVs", ResourceKind::Jwt => "JWTs", + ResourceKind::ApplicationToken => "application tokens", + ResourceKind::WebhookSigning => "webhook signing", ResourceKind::Runner => "runners", ResourceKind::RunnerConfig => "runner configs", ResourceKind::Token => "tokens", @@ -57,6 +61,8 @@ impl ResourceKind { ResourceKind::ActorGateway => "actor-gateway", ResourceKind::ActorKv => "actor-kv", ResourceKind::Jwt => "jwt", + ResourceKind::ApplicationToken => "application-token", + ResourceKind::WebhookSigning => "webhook-signing", ResourceKind::Runner => "runner", ResourceKind::RunnerConfig => "runner-config", ResourceKind::Token => "token", diff --git a/engine/packages/auth-policy/tests/signing_namespace.rs b/engine/packages/auth-policy/tests/signing_namespace.rs new file mode 100644 index 0000000000..edd6328584 --- /dev/null +++ b/engine/packages/auth-policy/tests/signing_namespace.rs @@ -0,0 +1,48 @@ +use rivet_auth_policy::{ + OperationKind as Op, OwnedGrant, ResourceKind as Resource, Scope, signing_namespace, +}; + +fn grant(namespace: Scope, resource: Resource) -> OwnedGrant { + OwnedGrant { + namespace, + resource, + target: Scope::Any, + operations: vec![Op::Create], + } +} + +#[test] +fn signing_requires_one_concrete_namespace_for_the_requested_permission() { + let application = Resource::ApplicationToken; + let webhook = Resource::WebhookSigning; + let one = grant(Scope::Id(1), application); + let wildcard = grant(Scope::Any, application); + assert_eq!(signing_namespace::(&[], application), None); + assert_eq!(signing_namespace(&[wildcard.clone()], application), None); + assert_eq!( + signing_namespace(&[one.clone(), one.clone(), wildcard], application), + Some(1) + ); + assert_eq!( + signing_namespace( + &[one.clone(), grant(Scope::Id(2), application)], + application + ), + None + ); + assert_eq!( + signing_namespace(&[one.clone(), grant(Scope::Id(2), webhook)], application), + Some(1) + ); + assert_eq!(signing_namespace(&[one.clone()], webhook), None); + assert_eq!( + signing_namespace(&[grant(Scope::Id(1), Resource::Jwt)], Resource::Jwt), + None + ); + let mut wrong_target = one.clone(); + wrong_target.target = Scope::Id(1); + assert_eq!(signing_namespace(&[wrong_target], application), None); + let mut wrong_operation = one; + wrong_operation.operations = vec![Op::Read]; + assert_eq!(signing_namespace(&[wrong_operation], application), None); +} diff --git a/engine/packages/config/src/config/auth.rs b/engine/packages/config/src/config/auth.rs index 02ecef8596..10f23244c1 100644 --- a/engine/packages/config/src/config/auth.rs +++ b/engine/packages/config/src/config/auth.rs @@ -47,6 +47,9 @@ impl Auth { #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Jwt { + /// Purpose-specific signing limits, shared by all replicas in a namespace. + #[serde(default)] + pub signing: Signing, /// Controls verification, issuance, and key maintenance. Defaults to true. pub enabled: Option, /// Pauses only new token minting. Defaults to true while JWT is enabled. @@ -143,6 +146,7 @@ impl Jwt { self.issuer_generation() > 0, "auth.jwt.issuer_generation must be positive" ); + self.signing.validate()?; let accepted = self.accepted_issuers()?; let mut unique = HashSet::new(); for issuer in &accepted { @@ -370,3 +374,59 @@ mod tests { } } } + +/// Fixed 60-second namespace budgets. Failed and cancelled attempts remain charged. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct Signing { + pub payload_bytes: u64, + pub defaults: SigningQuota, + /// Overrides indexed by immutable namespace ID, never namespace name. + pub namespaces: std::collections::BTreeMap, +} +impl Default for Signing { + fn default() -> Self { + Self { + payload_bytes: 32 * 1024 * 1024, + defaults: SigningQuota::default(), + namespaces: Default::default(), + } + } +} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(default, deny_unknown_fields)] +pub struct SigningQuota { + pub application_operations: u64, + pub webhook_operations: u64, + pub webhook_bytes: u64, +} +impl Default for SigningQuota { + fn default() -> Self { + Self { + application_operations: 300, + webhook_operations: 60, + webhook_bytes: 128 * 1024 * 1024, + } + } +} +impl Signing { + pub fn quota(&self, namespace_id: &str) -> SigningQuota { + self.namespaces + .get(namespace_id) + .copied() + .unwrap_or(self.defaults) + } + fn validate(&self) -> Result<()> { + ensure!( + (1..=128 * 1024 * 1024).contains(&self.payload_bytes), + "signing payload_bytes must be between 1 and 128 MiB" + ); + for id in self.namespaces.keys() { + ensure!( + !id.is_empty() && id.len() <= 64, + "signing namespace override requires an immutable namespace ID" + ); + } + Ok(()) + } +} diff --git a/engine/packages/engine/Cargo.toml b/engine/packages/engine/Cargo.toml index 2cb71ff786..5f3571b574 100644 --- a/engine/packages/engine/Cargo.toml +++ b/engine/packages/engine/Cargo.toml @@ -74,6 +74,8 @@ uuid.workspace = true lazy_static.workspace = true [dev-dependencies] +rivet-api-full.workspace = true +ed25519-dalek.workspace = true async-trait.workspace = true axum.workspace = true base64.workspace = true diff --git a/engine/packages/engine/tests/jwt_auth.rs b/engine/packages/engine/tests/jwt_auth.rs index 606ff08a7b..e3760fcdc1 100644 --- a/engine/packages/engine/tests/jwt_auth.rs +++ b/engine/packages/engine/tests/jwt_auth.rs @@ -8,15 +8,186 @@ mod api; #[path = "common/ctx.rs"] mod ctx; -const ADMIN_TOKEN: &str = "jwt-test-admin"; +fn test_admin_token() -> String { + // Every test uses a fresh database; do not reuse a credential across process-wide caches. + format!("jwt-test-admin-{}", rand::random::()) +} + +#[test] +fn publishes_public_only_jwks_for_issued_tokens() { + let admin_token = test_admin_token(); + run( + ctx::TestOpts::new(2) + .with_auth_admin_token(admin_token.clone()) + .with_timeout(90), + move |ctx| async move { + let client = Client::new(); + let follower_base = format!("http://127.0.0.1:{}", ctx.get_dc(2).guard_port()); + let namespace = format!("jwt-jwks-test-{}", rand::random::()); + api::peer::namespaces_create( + ctx.leader_dc().api_peer_port(), + rivet_api_peer::namespaces::CreateRequest { + name: namespace.clone(), + display_name: "JWKS Test".into(), + }, + ) + .await + .expect("failed to create JWKS namespace"); + let issued = retry_success(|| { + client + .post(format!("{follower_base}/auth/tokens")) + .bearer_auth(&admin_token) + .json(&json!({ + "namespace": namespace, + "duration": 60, + "grants": [{"resource": "actor", "target": "any", "operations": ["read"]}] + })) + }) + .await + .json::() + .await + .expect("failed to issue JWT"); + let kid = rivet_auth_jwt::peek_header(&issued.token) + .unwrap() + .kid + .to_string(); + let response = client + .get(format!("{follower_base}/.well-known/jwks.json")) + .send() + .await + .expect("failed to fetch JWKS"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()["content-type"], + "application/jwk-set+json" + ); + let document: Value = response.json().await.expect("failed to decode JWKS"); + let keys = document["keys"].as_array().expect("JWKS must have keys"); + let key = keys + .iter() + .find(|key| key["kid"] == kid) + .expect("signing key must be published"); + assert_eq!(key["kty"], "OKP"); + assert_eq!(key["crv"], "Ed25519"); + assert_eq!(key["alg"], "EdDSA"); + assert!( + key.get("d").is_none(), + "JWKS must never contain private keys" + ); + }, + ); +} + +#[test] +fn registers_and_removes_namespace_bound_jwks_issuers_from_a_follower() { + let admin_token = test_admin_token(); + run( + ctx::TestOpts::new(2) + .with_auth_admin_token(admin_token.clone()) + .with_timeout(90), + move |ctx| async move { + let namespace = format!("jwt-external-test-{}", rand::random::()); + api::peer::namespaces_create( + ctx.leader_dc().api_peer_port(), + rivet_api_peer::namespaces::CreateRequest { + name: namespace.clone(), + display_name: "External JWT Test".into(), + }, + ) + .await + .expect("failed to create external JWT namespace"); + let client = Client::new(); + let base = format!("http://127.0.0.1:{}", ctx.get_dc(2).guard_port()); + let endpoint = format!("{base}/namespaces/{namespace}/auth/issuers/example"); + let body = json!({ + "issuer": "https://jwt-issuer.gameinc.io", + "audience": "rivet-api", + "jwks_url": "https://jwt-issuer.gameinc.io/.well-known/jwks.json", + "algorithm": "EdDSA", + "grant_ceiling": [{ + "resource": "actor", "target": "any", "operations": ["read"] + }] + }); + let created = client + .put(&endpoint) + .bearer_auth(&admin_token) + .json(&body) + .send() + .await + .expect("failed to register JWKS issuer"); + assert_eq!( + created.status(), + StatusCode::OK, + "{}", + created.text().await.unwrap_or_default() + ); + let manager_jwt = retry_success(|| { + client + .post(format!("{base}/auth/tokens")) + .bearer_auth(&admin_token) + .json(&json!({ + "namespace": namespace, + "duration": 60, + "grants": [{"resource": "actor", "target": "any", "operations": ["read"]}] + })) + }) + .await + .json::() + .await + .expect("failed to issue a valid JWT for the enrollment check"); + let attempted_enrollment = client + .put(&endpoint) + .bearer_auth(&manager_jwt.token) + .json(&body) + .send() + .await + .expect("failed to test JWT self-enrollment"); + assert_eq!( + attempted_enrollment.status(), + StatusCode::FORBIDDEN, + "a JWT must not manage its own JWKS issuer registration" + ); + let listed = client + .get(format!("{base}/namespaces/{namespace}/auth/issuers")) + .bearer_auth(&admin_token) + .send() + .await + .expect("failed to list issuers"); + assert_eq!(listed.status(), StatusCode::OK); + let issuers: Value = listed.json().await.expect("invalid issuer list"); + assert_eq!(issuers["issuers"].as_array().unwrap().len(), 1); + assert_eq!(issuers["issuers"][0]["issuer"], body["issuer"]); + let removed = client + .delete(&endpoint) + .bearer_auth(&admin_token) + .send() + .await + .expect("failed to remove JWKS issuer"); + assert_eq!(removed.status(), StatusCode::OK); + let listed = client + .get(format!("{base}/namespaces/{namespace}/auth/issuers")) + .bearer_auth(&admin_token) + .send() + .await + .expect("failed to list issuers after removal"); + assert_eq!(listed.status(), StatusCode::OK); + let issuers: Value = listed + .json() + .await + .expect("invalid issuer list after removal"); + assert!(issuers["issuers"].as_array().unwrap().is_empty()); + }, + ); +} #[test] fn issues_and_inspects_jwts_from_a_non_writer_datacenter() { + let admin_token = test_admin_token(); run( ctx::TestOpts::new(2) - .with_auth_admin_token(ADMIN_TOKEN) + .with_auth_admin_token(admin_token.clone()) .with_timeout(90), - |ctx| async move { + move |ctx| async move { let namespace = format!("jwt-test-{}", rand::random::()); let created = api::peer::namespaces_create( ctx.leader_dc().api_peer_port(), @@ -44,7 +215,7 @@ fn issues_and_inspects_jwts_from_a_non_writer_datacenter() { let issued = retry_success(|| { client .post(format!("{follower_base}/auth/tokens")) - .bearer_auth(ADMIN_TOKEN) + .bearer_auth(&admin_token) .json(&issue_body) }) .await @@ -78,6 +249,109 @@ fn issues_and_inspects_jwts_from_a_non_writer_datacenter() { .expect("failed to list actors with a JWT"); assert_eq!(actor_list.status(), StatusCode::OK); + let acl_actor_list = client + .get(format!("{follower_base}/actors/names")) + .bearer_auth(&admin_token) + .query(&[("namespace", namespace.as_str())]) + .send() + .await + .expect("failed to list actors with an ACL credential"); + assert_eq!(acl_actor_list.status(), StatusCode::OK); + + let missing_credential = client + .get(format!("{follower_base}/actors/names")) + .query(&[("namespace", namespace.as_str())]) + .send() + .await + .expect("failed to test a missing credential"); + assert_error_response( + missing_credential, + StatusCode::UNAUTHORIZED, + "invalid_token", + ) + .await; + + let create_acl = |body: Value| { + client + .post(format!("{follower_base}/acl/tokens")) + .bearer_auth(&admin_token) + .json(&body) + }; + let limited_acl = create_acl(json!({ "roles": [] })) + .send() + .await + .expect("failed to create ACL credential without grants"); + assert_eq!(limited_acl.status(), StatusCode::OK); + let limited_acl = limited_acl + .json::() + .await + .expect("failed to decode ACL credential"); + let denied_acl = client + .get(format!("{follower_base}/actors/names")) + .bearer_auth(&limited_acl.token) + .query(&[("namespace", namespace.as_str())]) + .send() + .await + .expect("failed to test insufficient ACL grants"); + assert_error_response( + denied_acl, + StatusCode::FORBIDDEN, + "insufficient_permissions", + ) + .await; + + let expired_acl = create_acl(json!({ "roles": [], "duration": 0 })) + .send() + .await + .expect("failed to create expired ACL credential"); + assert_eq!(expired_acl.status(), StatusCode::OK); + let expired_acl = expired_acl + .json::() + .await + .expect("failed to decode expired ACL credential"); + tokio::time::sleep(Duration::from_millis(5)).await; + let expired_acl_response = client + .get(format!("{follower_base}/actors/names")) + .bearer_auth(&expired_acl.token) + .query(&[("namespace", namespace.as_str())]) + .send() + .await + .expect("failed to test expired ACL credential"); + assert_error_response( + expired_acl_response, + StatusCode::UNAUTHORIZED, + "token_expired", + ) + .await; + + // The ACL store deliberately contains this reserved-looking bearer. It must still take + // the JWT path and fail closed, never becoming an authorized ACL request. + let reserved_acl_token = format!( + "eyJ0eXAiOiJyaXZldC1hdXRoK2p3dCIsImFsZyI6IkVkRFNBIn0.invalid.{:016x}", + rand::random::() + ); + let reserved_acl = create_acl(json!({ + "roles": ["admin"], + "token": reserved_acl_token, + })) + .send() + .await + .expect("failed to create reserved-looking ACL credential"); + assert_eq!(reserved_acl.status(), StatusCode::OK); + let reserved_acl_response = client + .get(format!("{follower_base}/actors/names")) + .bearer_auth(&reserved_acl_token) + .query(&[("namespace", namespace.as_str())]) + .send() + .await + .expect("failed to test reserved JWT fallback"); + assert_error_response( + reserved_acl_response, + StatusCode::UNAUTHORIZED, + "invalid_token", + ) + .await; + let insufficient_body = json!({ "namespace": namespace, "duration": 60, @@ -90,7 +364,7 @@ fn issues_and_inspects_jwts_from_a_non_writer_datacenter() { let insufficient = retry_success(|| { client .post(format!("{follower_base}/auth/tokens")) - .bearer_auth(ADMIN_TOKEN) + .bearer_auth(&admin_token) .json(&insufficient_body) }) .await @@ -169,7 +443,7 @@ fn issues_and_inspects_jwts_from_a_non_writer_datacenter() { "{follower_base}/namespaces/{}/auth/tokens", created.namespace.name )) - .bearer_auth(ADMIN_TOKEN) + .bearer_auth(&admin_token) .json(&issue_body) .send() .await @@ -179,6 +453,56 @@ fn issues_and_inspects_jwts_from_a_non_writer_datacenter() { ); } +#[test] +fn expired_jwt_returns_auth_token_expired() { + let admin_token = test_admin_token(); + run( + ctx::TestOpts::new(1) + .with_auth_admin_token(admin_token.clone()) + .with_timeout(90), + move |ctx| async move { + let namespace = format!("jwt-expired-test-{}", rand::random::()); + api::peer::namespaces_create( + ctx.leader_dc().api_peer_port(), + rivet_api_peer::namespaces::CreateRequest { + name: namespace.clone(), + display_name: "Expired JWT Test".into(), + }, + ) + .await + .expect("failed to create JWT namespace"); + + let client = Client::new(); + let base = format!("http://127.0.0.1:{}", ctx.leader_dc().guard_port()); + let issued = retry_success(|| { + client + .post(format!("{base}/auth/tokens")) + .bearer_auth(&admin_token) + .json(&json!({ + "namespace": namespace, + "duration": 1, + "grants": [{ "resource": "actor", "target": "any", "operations": ["list"] }] + })) + }) + .await + .json::() + .await + .expect("failed to decode short-lived JWT"); + + // Verification permits a small clock skew after exp; wait past the same protocol bound. + tokio::time::sleep(Duration::from_secs(rivet_auth_jwt::PROTOCOL_CLOCK_SKEW + 2)).await; + let expired = client + .get(format!("{base}/actors/names")) + .bearer_auth(&issued.token) + .query(&[("namespace", namespace.as_str())]) + .send() + .await + .expect("failed to test expired JWT"); + assert_error_response(expired, StatusCode::UNAUTHORIZED, "token_expired").await; + }, + ); +} + fn run(opts: ctx::TestOpts, test_fn: F) where F: FnOnce(ctx::TestCtx) -> Fut, @@ -202,11 +526,25 @@ async fn assert_error_response( expected_error_code: &str, ) -> Value { assert_eq!(response.status(), expected_status); + assert_eq!( + response.headers()[reqwest::header::CONTENT_TYPE], + "application/json" + ); let body = response .json::() .await .expect("failed to decode error response"); + assert_eq!(body["group"], "auth"); assert_eq!(body["code"], expected_error_code); + assert!( + body["message"] + .as_str() + .is_some_and(|message| !message.is_empty()) + ); + assert!( + body.get("metadata").is_none(), + "authentication errors must not disclose metadata" + ); body } diff --git a/engine/packages/engine/tests/purpose_signing.rs b/engine/packages/engine/tests/purpose_signing.rs new file mode 100644 index 0000000000..bfdb4f0859 --- /dev/null +++ b/engine/packages/engine/tests/purpose_signing.rs @@ -0,0 +1,150 @@ +//! End-to-end purpose isolation and DS-compatible signing through a follower API. +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use reqwest::{Client, StatusCode}; +use serde_json::{Value, json}; +use std::time::{Duration, Instant}; +#[path = "common/api/mod.rs"] +mod api; +#[path = "common/ctx.rs"] +mod ctx; + +async fn success(request: reqwest::RequestBuilder) -> Value { + let response = request.send().await.unwrap(); + let status = response.status(); + let body: Value = response.json().await.unwrap(); + assert!(status.is_success(), "{status}: {body}"); + body +} + +#[test] +fn follower_issues_downstream_tokens_and_signs_with_separate_namespace_keys() { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let admin = format!("signing-test-{}", rand::random::()); + let ctx = ctx::TestCtx::new_with_opts(ctx::TestOpts::new(2).with_auth_admin_token(admin.clone()).with_timeout(180)).await.unwrap(); + tokio::time::timeout(Duration::from_secs(180), async { + let client = Client::new(); + let base = format!("http://127.0.0.1:{}", ctx.get_dc(2).guard_port()); + let name = format!("signing-{}", rand::random::()); + let ns = api::peer::namespaces_create(ctx.leader_dc().api_peer_port(), rivet_api_peer::namespaces::CreateRequest { name: name.clone(), display_name: "Signing Test".into() }).await.unwrap().namespace.namespace_id; + let manifest = json!({ + "rules": { + "signing-app": {"namespace": {"id": ns}, "resource": "application_token", "target": "any", "operations": ["create"]}, + "signing-webhook": {"namespace": {"id": ns}, "resource": "webhook_signing", "target": "any", "operations": ["create"]} + }, + "policies": {"signing": {"rules": ["signing-app", "signing-webhook"]}}, + "roles": {"signing": {"policies": ["signing"]}} + }); + success(client.post(format!("{base}/acl/manifest")).bearer_auth(&admin).json(&manifest)).await; + let acl = success(client.post(format!("{base}/acl/tokens")).bearer_auth(&admin).json(&json!({"roles": ["signing"], "duration": 120}))).await; + let token = acl["token"].as_str().unwrap(); + let response = client.post(format!("{base}/auth/webhooks/ensure")).bearer_auth(&admin).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "wildcard admin must not choose a namespace"); + + let missing = client.get(format!("{base}/namespaces/{ns}/auth/webhooks/jwks.json")).send().await.unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + assert_eq!(missing.json::().await.unwrap()["code"], "not_found"); + let metadata = success(client.post(format!("{base}/auth/webhooks/ensure")).bearer_auth(token)).await; + let kid = metadata["kid"].as_str().unwrap(); + assert!(kid.starts_with("ds_")); + let jwks_url = format!("{base}/namespaces/{ns}/auth/webhooks/jwks.json"); + let response = client.get(&jwks_url).send().await.unwrap(); + assert_eq!(response.headers()["cache-control"], "no-store"); + let jwks: Value = response.json().await.unwrap(); + let key = jwks["keys"].as_array().unwrap().iter().find(|k| k["kid"] == kid).unwrap(); + assert_eq!(key.as_object().unwrap().len(), 6); + let public: [u8;32] = URL_SAFE_NO_PAD.decode(key["x"].as_str().unwrap()).unwrap().try_into().unwrap(); + let payload = vec![0,255,b'\r',b'\n',b'{',b'}']; + let signed = success(client.post(format!("{base}/auth/webhooks/sign")).bearer_auth(token).header("content-type", "application/octet-stream").body(payload.clone())).await; + let mut message = format!("{}.", signed["timestamp"].as_str().unwrap()).into_bytes(); message.extend_from_slice(&payload); + let sig = ed25519_dalek::Signature::from_slice(&URL_SAFE_NO_PAD.decode(signed["signature"].as_str().unwrap()).unwrap()).unwrap(); + ed25519_dalek::VerifyingKey::from_bytes(&public).unwrap().verify_strict(&message, &sig).unwrap(); + let issued = success(client.post(format!("{base}/auth/application-tokens")).bearer_auth(token).json(&json!({"audience":"durable-streams", "purpose":"durable-streams.callback", "duration":86400, "claims":{"durable_streams":{"root":"https://streams.gameinc.io/streams", "subscription_id":"sub-1", "wake_id":"w-1", "generation":7, "operations":["callback"]}}}))).await; + assert!(issued["expires_ts"].as_i64().unwrap() <= acl["expires_ts"].as_i64().unwrap()); + let app = issued["token"].as_str().unwrap(); + let parts: Vec<_> = app.split('.').collect(); + let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap(); + let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); + assert_eq!(header["typ"], "rivet-app+jwt"); + assert_ne!(header["kid"], kid); + assert_eq!(claims["rivet_ns"], ns.to_string()); + assert!(claims.get("rivet_grants").is_none()); + for path in ["/auth/tokens/inspect", "/acl/tokens/inspect"] { + let response = client.get(format!("{base}{path}")).bearer_auth(app).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + let response = client.get(format!("{base}/gateway/no-actor/ping")).query(&[("rvt-namespace", name.as_str()),("rvt-method", "get"),("rvt-key","unused"),("rvt-token",app)]).send().await.unwrap(); + assert_eq!(response.json::().await.unwrap()["code"], "invalid_token"); + let mut ws_url = url::Url::parse(&format!("ws://127.0.0.1:{}/gateway/no-actor/socket", ctx.get_dc(2).guard_port())).unwrap(); + ws_url.query_pairs_mut().extend_pairs([("rvt-namespace",name.as_str()),("rvt-method","get"),("rvt-key","unused"),("rvt-token",app)]); + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + let mut ws_request = ws_url.as_str().into_client_request().unwrap(); + ws_request.headers_mut().insert("Sec-WebSocket-Protocol", "rivet".parse().unwrap()); + let (mut socket, _) = tokio_tungstenite::connect_async(ws_request).await.unwrap(); + use futures_util::StreamExt; + let rejection = tokio::time::timeout(Duration::from_secs(5), socket.next()).await.unwrap().unwrap().unwrap(); + match rejection { + tokio_tungstenite::tungstenite::Message::Close(Some(frame)) => assert!(frame.reason.starts_with("auth.invalid_token#")), + _ => panic!("application credential must close without an actor session"), + } + // Management uses independently authorized namespace scope; signing token cannot rotate. + let management = format!("{base}/namespaces/{name}/auth/webhook-keys"); + let status = success(client.get(&management).bearer_auth(&admin)).await; + let rotate = json!({"expected_generation":status["generation"]}); + assert_eq!(client.post(format!("{management}/rotate")).bearer_auth(token).json(&rotate).send().await.unwrap().status(), StatusCode::FORBIDDEN); + let staged = success(client.post(format!("{management}/rotate")).bearer_auth(&admin).json(&rotate)).await; + assert_eq!(staged["active_kid"], kid); + assert!(staged["pending_kid"].is_string()); + let stale = client.post(format!("{management}/rotate")).bearer_auth(&admin).json(&rotate).send().await.unwrap(); + assert_eq!(stale.status(), StatusCode::CONFLICT); + assert_eq!(stale.json::().await.unwrap()["code"], "conflict"); + let invalid = client.post(format!("{management}/revoke")).bearer_auth(&admin).json(&json!({"request_id":uuid::Uuid::new_v4().to_string(),"expected_generation":staged["generation"],"kids":["ds_unknown"]})).send().await.unwrap(); + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + assert_eq!(invalid.json::().await.unwrap()["code"], "invalid_input"); + let revoke = json!({"request_id":uuid::Uuid::new_v4().to_string(), "expected_generation":staged["generation"], "kids":[kid]}); + let revoked = success(client.post(format!("{management}/revoke")).bearer_auth(&admin).json(&revoke)).await; + assert_ne!(revoked["active_kid"], kid); + assert_eq!(success(client.post(format!("{management}/revoke")).bearer_auth(&admin).json(&revoke)).await, revoked); + let after = success(client.get(&jwks_url)).await; + assert!(!after["keys"].as_array().unwrap().iter().any(|key| key["kid"] == kid)); + let streams: Vec<_> = (0..4096).map(|i| json!({"stream":format!("{i:04}/{}", "%C3%A9".repeat(1020)),"offset":"18446744073709551615","generation":18446744073709551615u64})).collect(); + let snapshot = serde_json::to_vec(&json!({"streams":streams,"capability":"x".repeat(6144)})).unwrap(); + assert!(snapshot.len() < 32*1024*1024); + for (label, payload, samples) in [("1k",vec![b'x';1024],15), ("1m",vec![b'x';1024*1024],15), ("ds4096",snapshot,3)] { + let mut timings = Vec::new(); + for _ in 0..samples { + let start = Instant::now(); + success(client.post(format!("{base}/auth/webhooks/sign")).bearer_auth(token).header("content-type", "application/octet-stream").body(payload.clone())).await; + timings.push(start.elapsed().as_secs_f64()); + } + timings.sort_by(f64::total_cmp); + println!("SIGNING_BENCH label={label} bytes={} samples={samples} p50={} p95={} operations_per_second={}", payload.len(), timings[samples/2], timings[(samples*95/100).min(samples-1)], samples as f64 / timings.iter().sum::()); + } + let mut timings = Vec::new(); + for _ in 0..30 { + let start = Instant::now(); + success(client.post(format!("{base}/auth/application-tokens")).bearer_auth(token).json(&json!({"audience":"durable-streams", "purpose":"durable-streams.callback", "claims":{"app_data":{"generation":1}}}))).await; + timings.push(start.elapsed().as_secs_f64()); + } + timings.sort_by(f64::total_cmp); + println!("SIGNING_BENCH label=jwt samples=30 p50={} p95={} operations_per_second={}", timings[15],timings[28],30.0/timings.iter().sum::()); + // Both public datacenters spend the same durable namespace operation budget. + let leader_base = format!("http://127.0.0.1:{}",ctx.leader_dc().guard_port()); + // 35 attempts were spent above. Leave exactly one admission, then race two DCs. + for i in 0..24 { + let endpoint = if i % 2 == 0 { &base } else { &leader_base }; + success(client.post(format!("{endpoint}/auth/webhooks/ensure")).bearer_auth(token)).await; + } + let (left,right) = tokio::join!( + client.post(format!("{base}/auth/webhooks/ensure")).bearer_auth(token).send(), + client.post(format!("{leader_base}/auth/webhooks/ensure")).bearer_auth(token).send() + ); + let mut statuses = [left.unwrap().status().as_u16(),right.unwrap().status().as_u16()]; + statuses.sort(); assert_eq!(statuses, [200,429], "only one concurrent contender may spend the last admission"); + for endpoint in [&base,&leader_base] { + let response = client.post(format!("{endpoint}/auth/webhooks/ensure")).bearer_auth(token).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(response.headers()["retry-after"], "60"); + } + }).await.unwrap(); + }); +} diff --git a/engine/packages/engine/tests/sdk_signing_bytes.rs b/engine/packages/engine/tests/sdk_signing_bytes.rs new file mode 100644 index 0000000000..bf29e9ce6f --- /dev/null +++ b/engine/packages/engine/tests/sdk_signing_bytes.rs @@ -0,0 +1,38 @@ +//! The generated Rust client must preserve arbitrary HTTP request bytes. +use axum::{Json, Router, body::Bytes, http::HeaderMap, routing::post}; +use serde_json::json; + +#[tokio::test] +async fn generated_rust_signing_client_sends_exact_bytes() { + for payload in [ + vec![], + vec![0], + vec![255, 254, 128], + b"\r\n{ \"a\": 1 }\r\n".to_vec(), + ] { + let expected = payload.clone(); + let app = Router::new().route( + "/auth/webhooks/sign", + post(move |headers: HeaderMap, body: Bytes| async move { + assert_eq!(headers["content-type"], "application/octet-stream"); + assert_eq!(headers["authorization"], "Bearer sdk-test"); + assert_eq!(&body[..], expected.as_slice()); + Json(json!({"timestamp":"123", "kid":"ds_test", "signature":"test"})) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async { axum::serve(listener, app).await.unwrap() }); + let mut configuration = rivet_api_full::apis::configuration::Configuration::new(); + configuration.base_path = format!("http://{address}"); + configuration.bearer_access_token = Some("sdk-test".into()); + let response = rivet_api_full::apis::auth_webhook_signing_api::auth_webhooks_sign( + &configuration, + payload, + ) + .await + .unwrap(); + assert_eq!(response.timestamp, "123"); + server.abort(); + } +} diff --git a/engine/packages/guard/src/routing/api_public.rs b/engine/packages/guard/src/routing/api_public.rs index 4cf2b0784b..04ad9c7e37 100644 --- a/engine/packages/guard/src/routing/api_public.rs +++ b/engine/packages/guard/src/routing/api_public.rs @@ -15,10 +15,35 @@ use crate::{errors, metrics}; struct ApiPublicService { router: axum::Router, + stream_signing: bool, } #[async_trait] impl CustomServeTrait for ApiPublicService { + fn streams_request_body(&self) -> bool { + self.stream_signing + } + + async fn handle_streaming_request( + &self, + req: Request, + _ctx: &mut RequestContext, + ) -> Result> { + // Signing handlers authenticate and reserve capacity before polling the original body. + // Guard must neither prebuffer these bytes nor automatically replay a signing operation. + let response = self + .router + .clone() + .call(req.map(axum::body::Body::new)) + .await?; + let (parts, body) = response.into_parts(); + let bytes = body.collect().await?.to_bytes(); + Ok(Response::from_parts( + parts, + ResponseBody::Full(Full::new(bytes)), + )) + } + #[tracing::instrument(skip_all)] async fn handle_request( &self, @@ -54,6 +79,7 @@ pub async fn route_request( ctx: &StandaloneCtx, shared_state: &crate::shared_state::SharedState, target: &str, + request_path: &str, ) -> Result> { // Check target if target != "api-public" { @@ -79,7 +105,14 @@ pub async fn route_request( ) .await?; - let service = Arc::new(ApiPublicService { router }); + let path = request_path.split('?').next().unwrap_or(request_path); + let stream_signing = path == "/auth/application-tokens" + || path.starts_with("/auth/webhooks/") + || (path.starts_with("/namespaces/") && path.contains("/auth/webhook-keys")); + let service = Arc::new(ApiPublicService { + router, + stream_signing, + }); return Ok(Some(RoutingOutput::CustomServe(service))); } diff --git a/engine/packages/guard/src/routing/mod.rs b/engine/packages/guard/src/routing/mod.rs index a8d580312d..43e6441e82 100644 --- a/engine/packages/guard/src/routing/mod.rs +++ b/engine/packages/guard/src/routing/mod.rs @@ -326,7 +326,7 @@ pub fn create_routing_function(ctx: &StandaloneCtx, shared_state: SharedState) - if let Some(routing_output) = phase_timeout( route_dispatch_phase("api_public_header"), ctx.config().guard().route_dispatch_timeout(), - api_public::route_request(&ctx, &shared_state, &target), + api_public::route_request(&ctx, &shared_state, &target, req_ctx.path()), |elapsed, timeout| { route_dispatch_timeout("api_public_header", elapsed, timeout) }, @@ -342,7 +342,12 @@ pub fn create_routing_function(ctx: &StandaloneCtx, shared_state: SharedState) - if let Some(routing_output) = phase_timeout( route_dispatch_phase("api_public_default"), ctx.config().guard().route_dispatch_timeout(), - api_public::route_request(&ctx, &shared_state, "api-public"), + api_public::route_request( + &ctx, + &shared_state, + "api-public", + req_ctx.path(), + ), |elapsed, timeout| { route_dispatch_timeout("api_public_default", elapsed, timeout) }, diff --git a/engine/packages/namespace/src/ops/exists_authoritative.rs b/engine/packages/namespace/src/ops/exists_authoritative.rs new file mode 100644 index 0000000000..5832f41489 --- /dev/null +++ b/engine/packages/namespace/src/ops/exists_authoritative.rs @@ -0,0 +1,29 @@ +use gas::prelude::*; +use universaldb::utils::IsolationLevel::Serializable; + +#[derive(Debug)] +pub struct Input { + pub namespace_id: Id, +} + +/// Bypasses metadata caches for signing admission. Only the namespace authority reads it. +#[operation] +pub async fn namespace_exists_authoritative(ctx: &OperationCtx, input: &Input) -> Result { + anyhow::ensure!( + ctx.config().is_leader(), + "namespace existence reads are leader-only" + ); + let key = crate::keys::NameKey::new(input.namespace_id); + ctx.udb()? + .txn("namespace_exists_authoritative", |tx| { + let key = &key; + async move { + Ok(tx + .with_subspace(crate::keys::subspace()) + .read_opt(key, Serializable) + .await? + .is_some()) + } + }) + .await +} diff --git a/engine/packages/namespace/src/ops/mod.rs b/engine/packages/namespace/src/ops/mod.rs index 74fc79b4a9..f9f2c49a77 100644 --- a/engine/packages/namespace/src/ops/mod.rs +++ b/engine/packages/namespace/src/ops/mod.rs @@ -1,5 +1,8 @@ +pub mod exists_authoritative; pub mod get_global; pub mod get_local; pub mod list; pub mod resolve_for_name_global; pub mod resolve_for_name_local; + +pub mod signing_recovery_page; diff --git a/engine/packages/namespace/src/ops/signing_recovery_page.rs b/engine/packages/namespace/src/ops/signing_recovery_page.rs new file mode 100644 index 0000000000..0bbc192362 --- /dev/null +++ b/engine/packages/namespace/src/ops/signing_recovery_page.rs @@ -0,0 +1,48 @@ +//! Bounded namespace enumeration for recovering idle signing workflows after leader changes. +use futures_util::TryStreamExt; +use gas::prelude::*; +use universaldb::{KeySelector, utils::IsolationLevel::Snapshot}; +#[derive(Debug)] +pub struct Input { + pub after: Option>, +} +#[derive(Debug, Serialize, Deserialize)] +pub struct Output { + pub namespace_ids: Vec, + pub next: Option>, +} +#[operation] +pub async fn namespace_signing_recovery_page(ctx: &OperationCtx, input: &Input) -> Result { + anyhow::ensure!( + ctx.config().is_leader(), + "namespace enumeration is leader-only" + ); + ctx.udb()? + .txn("namespace_signing_recovery_page", |tx| async move { + let subspace = crate::keys::subspace(); + let mut range: universaldb::RangeOption = (&subspace).into(); + if let Some(after) = &input.after { + range.begin = KeySelector::first_greater_than(after.clone()); + } + range.limit = Some(512); + let mut stream = tx.get_ranges_keyvalues(range, Snapshot); + let mut namespace_ids = Vec::new(); + let mut last = None; + let mut count = 0; + while let Some(entry) = stream.try_next().await? { + count += 1; + last = Some(entry.key().to_vec()); + if let Ok(key) = subspace.unpack::(entry.key()) { + // TupleUnpack alone accepts other three-field namespace keys. + if subspace.pack(&key) == entry.key() { + namespace_ids.push(key.namespace_id()); + } + } + } + Ok(Output { + namespace_ids, + next: if count == 512 { last } else { None }, + }) + }) + .await +} diff --git a/engine/packages/universaldb/src/utils/keys.rs b/engine/packages/universaldb/src/utils/keys.rs index 6974332972..c1e7cdb6b4 100644 --- a/engine/packages/universaldb/src/utils/keys.rs +++ b/engine/packages/universaldb/src/utils/keys.rs @@ -169,4 +169,5 @@ define_keys! { (141, JWT, "jwt"), (142, KEY_RING, "key_ring"), (143, PRIVATE_KEY, "private_key"), + (144, EXTERNAL_ISSUERS, "external_issuers"), } diff --git a/engine/sdks/api/fern/generators.yml b/engine/sdks/api/fern/generators.yml index c031dfeb18..b62113ab41 100644 --- a/engine/sdks/api/fern/generators.yml +++ b/engine/sdks/api/fern/generators.yml @@ -19,7 +19,9 @@ groups: skipResponseValidation: true allowCustomFetcher: true - name: fernapi/fern-go-sdk - version: 0.9.3 + version: 0.13.0 output: location: local-file-system path: ../../go/api-full + config: + includeLegacyClientOptions: true diff --git a/engine/sdks/go/api-full/auth_application_tokens.go b/engine/sdks/go/api-full/auth_application_tokens.go new file mode 100644 index 0000000000..e839b26150 --- /dev/null +++ b/engine/sdks/go/api-full/auth_application_tokens.go @@ -0,0 +1,11 @@ +// This file was auto-generated by Fern from our API Definition. + +package api + +type AuthApplicationTokenCreateRequest struct { + Audience string `json:"audience"` + Claims map[string]interface{} `json:"claims,omitempty"` + Duration *int64 `json:"duration,omitempty"` + Purpose string `json:"purpose"` + Subject *string `json:"subject,omitempty"` +} diff --git a/engine/sdks/go/api-full/auth_issuers.go b/engine/sdks/go/api-full/auth_issuers.go new file mode 100644 index 0000000000..418ec9ec2d --- /dev/null +++ b/engine/sdks/go/api-full/auth_issuers.go @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +package api + +type AuthIssuerUpsertRequest struct { + // Pinned signature algorithm: EdDSA, RS256, or ES256. + Algorithm string `json:"algorithm"` + // Required, exact `aud` claim for Rivet (never optional). + Audience string `json:"audience"` + // Maximum access this issuer may embed in a signed Rivet JWT. + GrantCeiling []*AuthIssuerGrant `json:"grant_ceiling,omitempty"` + // Exact `iss` claim to trust for this namespace. + Issuer string `json:"issuer"` + JwksUrl string `json:"jwks_url"` +} diff --git a/engine/sdks/go/api-full/auth_webhook_keys.go b/engine/sdks/go/api-full/auth_webhook_keys.go new file mode 100644 index 0000000000..5436bba8ef --- /dev/null +++ b/engine/sdks/go/api-full/auth_webhook_keys.go @@ -0,0 +1,13 @@ +// This file was auto-generated by Fern from our API Definition. + +package api + +type AuthWebhookRevokeRequest struct { + ExpectedGeneration int64 `json:"expected_generation"` + Kids []string `json:"kids,omitempty"` + RequestId string `json:"request_id"` +} + +type AuthWebhookRotateRequest struct { + ExpectedGeneration int64 `json:"expected_generation"` +} diff --git a/engine/sdks/go/api-full/authapplicationtokens/client.go b/engine/sdks/go/api-full/authapplicationtokens/client.go new file mode 100644 index 0000000000..79d13f98a3 --- /dev/null +++ b/engine/sdks/go/api-full/authapplicationtokens/client.go @@ -0,0 +1,67 @@ +// This file was auto-generated by Fern from our API Definition. + +package authapplicationtokens + +import ( + context "context" + http "net/http" + sdk "sdk" + core "sdk/core" + option "sdk/option" +) + +type Client struct { + baseURL string + caller *core.Caller + header http.Header +} + +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) + return &Client{ + baseURL: options.BaseURL, + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), + } +} + +func (c *Client) Create( + ctx context.Context, + request *sdk.AuthApplicationTokenCreateRequest, + opts ...option.RequestOption, +) (*sdk.AuthApplicationTokenCreateResponse, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := baseURL + "/" + "auth/application-tokens" + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response *sdk.AuthApplicationTokenCreateResponse + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} diff --git a/engine/sdks/go/api-full/authissuers/client.go b/engine/sdks/go/api-full/authissuers/client.go new file mode 100644 index 0000000000..11e64e0d1a --- /dev/null +++ b/engine/sdks/go/api-full/authissuers/client.go @@ -0,0 +1,184 @@ +// This file was auto-generated by Fern from our API Definition. + +package authissuers + +import ( + context "context" + fmt "fmt" + http "net/http" + sdk "sdk" + core "sdk/core" + option "sdk/option" +) + +type Client struct { + baseURL string + caller *core.Caller + header http.Header +} + +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) + return &Client{ + baseURL: options.BaseURL, + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), + } +} + +func (c *Client) List( + ctx context.Context, + // Namespace name + namespace string, + opts ...option.RequestOption, +) (*sdk.AuthIssuerListResponse, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := fmt.Sprintf(baseURL+"/"+"namespaces/%v/auth/issuers", namespace) + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response *sdk.AuthIssuerListResponse + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} + +func (c *Client) Get( + ctx context.Context, + // Namespace name + namespace string, + // Issuer registration name + issuerName string, + opts ...option.RequestOption, +) (*sdk.AuthIssuerGetResponse, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := fmt.Sprintf(baseURL+"/"+"namespaces/%v/auth/issuers/%v", namespace, issuerName) + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response *sdk.AuthIssuerGetResponse + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} + +func (c *Client) Upsert( + ctx context.Context, + // Namespace name + namespace string, + // Issuer registration name + issuerName string, + request *sdk.AuthIssuerUpsertRequest, + opts ...option.RequestOption, +) (*sdk.AuthIssuerUpsertResponse, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := fmt.Sprintf(baseURL+"/"+"namespaces/%v/auth/issuers/%v", namespace, issuerName) + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response *sdk.AuthIssuerUpsertResponse + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodPut, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} + +func (c *Client) Delete( + ctx context.Context, + // Namespace name + namespace string, + // Issuer registration name + issuerName string, + opts ...option.RequestOption, +) (sdk.AuthIssuerDeleteResponse, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := fmt.Sprintf(baseURL+"/"+"namespaces/%v/auth/issuers/%v", namespace, issuerName) + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response sdk.AuthIssuerDeleteResponse + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodDelete, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} diff --git a/engine/sdks/go/api-full/authjwks/client.go b/engine/sdks/go/api-full/authjwks/client.go new file mode 100644 index 0000000000..0c52cbb112 --- /dev/null +++ b/engine/sdks/go/api-full/authjwks/client.go @@ -0,0 +1,64 @@ +// This file was auto-generated by Fern from our API Definition. + +package authjwks + +import ( + context "context" + http "net/http" + core "sdk/core" + option "sdk/option" +) + +type Client struct { + baseURL string + caller *core.Caller + header http.Header +} + +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) + return &Client{ + baseURL: options.BaseURL, + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), + } +} + +func (c *Client) Get( + ctx context.Context, + opts ...option.RequestOption, +) (interface{}, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := baseURL + "/" + ".well-known/jwks.json" + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response interface{} + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} diff --git a/engine/sdks/go/api-full/authtokens/client.go b/engine/sdks/go/api-full/authtokens/client.go index 1825f4d879..68dc8b67d5 100644 --- a/engine/sdks/go/api-full/authtokens/client.go +++ b/engine/sdks/go/api-full/authtokens/client.go @@ -7,6 +7,7 @@ import ( http "net/http" sdk "sdk" core "sdk/core" + option "sdk/option" ) type Client struct { @@ -15,34 +16,49 @@ type Client struct { header http.Header } -func NewClient(opts ...core.ClientOption) *Client { - options := core.NewClientOptions() - for _, opt := range opts { - opt(options) - } +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) return &Client{ baseURL: options.BaseURL, - caller: core.NewCaller(options.HTTPClient), - header: options.ToHeader(), + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), } } -func (c *Client) Create(ctx context.Context, request *sdk.AuthTokenCreateRequest) (*sdk.AuthTokenCreateResponse, error) { +func (c *Client) Create( + ctx context.Context, + request *sdk.AuthTokenCreateRequest, + opts ...option.RequestOption, +) (*sdk.AuthTokenCreateResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "auth/tokens" + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.AuthTokenCreateResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodPost, - Headers: c.header, - Request: request, - Response: &response, + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, }, ); err != nil { return nil, err @@ -50,21 +66,33 @@ func (c *Client) Create(ctx context.Context, request *sdk.AuthTokenCreateRequest return response, nil } -func (c *Client) Inspect(ctx context.Context) (*sdk.AuthTokenInspectResponse, error) { +func (c *Client) Inspect( + ctx context.Context, + opts ...option.RequestOption, +) (*sdk.AuthTokenInspectResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "auth/tokens/inspect" + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.AuthTokenInspectResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err diff --git a/engine/sdks/go/api-full/authwebhookdiscovery/client.go b/engine/sdks/go/api-full/authwebhookdiscovery/client.go new file mode 100644 index 0000000000..9b11fc9009 --- /dev/null +++ b/engine/sdks/go/api-full/authwebhookdiscovery/client.go @@ -0,0 +1,104 @@ +// This file was auto-generated by Fern from our API Definition. + +package authwebhookdiscovery + +import ( + context "context" + fmt "fmt" + http "net/http" + sdk "sdk" + core "sdk/core" + option "sdk/option" +) + +type Client struct { + baseURL string + caller *core.Caller + header http.Header +} + +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) + return &Client{ + baseURL: options.BaseURL, + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), + } +} + +func (c *Client) AuthWebhooksMetadata( + ctx context.Context, + // Immutable namespace ID + namespace string, + opts ...option.RequestOption, +) (*sdk.AuthWebhookMetadata, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := fmt.Sprintf(baseURL+"/"+"namespaces/%v/auth/webhooks", namespace) + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response *sdk.AuthWebhookMetadata + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} + +func (c *Client) AuthWebhooksJwks( + ctx context.Context, + // Immutable namespace ID + namespace string, + opts ...option.RequestOption, +) (*sdk.AuthWebhookJwks, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := fmt.Sprintf(baseURL+"/"+"namespaces/%v/auth/webhooks/jwks.json", namespace) + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response *sdk.AuthWebhookJwks + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} diff --git a/engine/sdks/go/api-full/authwebhookkeys/client.go b/engine/sdks/go/api-full/authwebhookkeys/client.go new file mode 100644 index 0000000000..bd84f6299d --- /dev/null +++ b/engine/sdks/go/api-full/authwebhookkeys/client.go @@ -0,0 +1,178 @@ +// This file was auto-generated by Fern from our API Definition. + +package authwebhookkeys + +import ( + context "context" + fmt "fmt" + http "net/http" + sdk "sdk" + core "sdk/core" + option "sdk/option" +) + +type Client struct { + baseURL string + caller *core.Caller + header http.Header +} + +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) + return &Client{ + baseURL: options.BaseURL, + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), + } +} + +func (c *Client) AuthWebhooksEnsure( + ctx context.Context, + opts ...option.RequestOption, +) (*sdk.AuthWebhookMetadata, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := baseURL + "/" + "auth/webhooks/ensure" + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response *sdk.AuthWebhookMetadata + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} + +func (c *Client) Get( + ctx context.Context, + // Namespace name + namespace string, + opts ...option.RequestOption, +) (*sdk.AuthWebhookKeyStatus, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := fmt.Sprintf(baseURL+"/"+"namespaces/%v/auth/webhook-keys", namespace) + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response *sdk.AuthWebhookKeyStatus + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} + +func (c *Client) Revoke( + ctx context.Context, + // Namespace name + namespace string, + request *sdk.AuthWebhookRevokeRequest, + opts ...option.RequestOption, +) (*sdk.AuthWebhookKeyStatus, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := fmt.Sprintf(baseURL+"/"+"namespaces/%v/auth/webhook-keys/revoke", namespace) + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response *sdk.AuthWebhookKeyStatus + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} + +func (c *Client) Rotate( + ctx context.Context, + // Namespace name + namespace string, + request *sdk.AuthWebhookRotateRequest, + opts ...option.RequestOption, +) (*sdk.AuthWebhookKeyStatus, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := fmt.Sprintf(baseURL+"/"+"namespaces/%v/auth/webhook-keys/rotate", namespace) + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + + var response *sdk.AuthWebhookKeyStatus + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} diff --git a/engine/sdks/go/api-full/authwebhooksigning/client.go b/engine/sdks/go/api-full/authwebhooksigning/client.go new file mode 100644 index 0000000000..7f136435aa --- /dev/null +++ b/engine/sdks/go/api-full/authwebhooksigning/client.go @@ -0,0 +1,71 @@ +// This file was auto-generated by Fern from our API Definition. + +package authwebhooksigning + +import ( + bytes "bytes" + context "context" + http "net/http" + sdk "sdk" + core "sdk/core" + option "sdk/option" +) + +type Client struct { + baseURL string + caller *core.Caller + header http.Header +} + +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) + return &Client{ + baseURL: options.BaseURL, + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), + } +} + +func (c *Client) AuthWebhooksSign( + ctx context.Context, + request []byte, + opts ...option.RequestOption, +) (*sdk.AuthWebhookSignResponse, error) { + options := core.NewRequestOptions(opts...) + + baseURL := "" + if c.baseURL != "" { + baseURL = c.baseURL + } + if options.BaseURL != "" { + baseURL = options.BaseURL + } + endpointURL := baseURL + "/" + "auth/webhooks/sign" + + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + headers.Set("Content-Type", "application/octet-stream") + + requestBuffer := bytes.NewBuffer(request) + + var response *sdk.AuthWebhookSignResponse + if err := c.caller.Call( + ctx, + &core.CallParams{ + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: requestBuffer, + Response: &response, + }, + ); err != nil { + return nil, err + } + return response, nil +} diff --git a/engine/sdks/go/api-full/client/client.go b/engine/sdks/go/api-full/client/client.go index cd991774d8..585bf53a4d 100644 --- a/engine/sdks/go/api-full/client/client.go +++ b/engine/sdks/go/api-full/client/client.go @@ -73,11 +73,20 @@ func NewClient(opts ...core.ClientOption) *Client { // - GET /actors (fanout) // // ## Optimized Alternative Routes -func (c *Client) ActorsList(ctx context.Context, request *sdk.ActorsListRequest) (*sdk.ActorsListResponse, error) { +func (c *Client) ActorsList( + ctx context.Context, + request *sdk.ActorsListRequest, + opts ...option.RequestOption, +) (*sdk.ActorsListResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "actors" queryParams := make(url.Values) @@ -107,14 +116,18 @@ func (c *Client) ActorsList(ctx context.Context, request *sdk.ActorsListRequest) endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.ActorsListResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err @@ -138,11 +151,20 @@ func (c *Client) ActorsList(ctx context.Context, request *sdk.ActorsListRequest) // - [pegboard::workflows::actor] Create actor workflow (includes Epoxy key allocation) // // actor::get will always be in the same datacenter. -func (c *Client) ActorsCreate(ctx context.Context, request *sdk.ActorsCreateRequest) (*sdk.ActorsCreateResponse, error) { +func (c *Client) ActorsCreate( + ctx context.Context, + request *sdk.ActorsCreateRequest, + opts ...option.RequestOption, +) (*sdk.ActorsCreateResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "actors" queryParams := make(url.Values) @@ -151,15 +173,19 @@ func (c *Client) ActorsCreate(ctx context.Context, request *sdk.ActorsCreateRequ endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.ActorsCreateResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodPost, - Headers: c.header, - Request: request, - Response: &response, + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, }, ); err != nil { return nil, err @@ -192,11 +218,20 @@ func (c *Client) ActorsCreate(ctx context.Context, request *sdk.ActorsCreateRequ // actor::get will always be in the same datacenter. // // ## Optimized Alternative Routes -func (c *Client) ActorsGetOrCreate(ctx context.Context, request *sdk.ActorsGetOrCreateRequest) (*sdk.ActorsGetOrCreateResponse, error) { +func (c *Client) ActorsGetOrCreate( + ctx context.Context, + request *sdk.ActorsGetOrCreateRequest, + opts ...option.RequestOption, +) (*sdk.ActorsGetOrCreateResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "actors" queryParams := make(url.Values) @@ -205,15 +240,19 @@ func (c *Client) ActorsGetOrCreate(ctx context.Context, request *sdk.ActorsGetOr endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.ActorsGetOrCreateResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodPut, - Headers: c.header, - Request: request, - Response: &response, + URL: endpointURL, + Method: http.MethodPut, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, }, ); err != nil { return nil, err @@ -225,11 +264,20 @@ func (c *Client) ActorsGetOrCreate(ctx context.Context, request *sdk.ActorsGetOr // // - GET /actors/names (fanout) // - [api-peer] namespace::ops::resolve_for_name_global -func (c *Client) ActorsListNames(ctx context.Context, request *sdk.ActorsListNamesRequest) (*sdk.ActorsListNamesResponse, error) { +func (c *Client) ActorsListNames( + ctx context.Context, + request *sdk.ActorsListNamesRequest, + opts ...option.RequestOption, +) (*sdk.ActorsListNamesResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "actors/names" queryParams := make(url.Values) @@ -244,14 +292,18 @@ func (c *Client) ActorsListNames(ctx context.Context, request *sdk.ActorsListNam endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.ActorsListNamesResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err @@ -263,11 +315,21 @@ func (c *Client) ActorsListNames(ctx context.Context, request *sdk.ActorsListNam // // - DELETE /actors/{} // - [api-peer] namespace::ops::resolve_for_name_global -func (c *Client) ActorsDelete(ctx context.Context, actorId sdk.RivetId, request *sdk.ActorsDeleteRequest) (sdk.ActorsDeleteResponse, error) { +func (c *Client) ActorsDelete( + ctx context.Context, + actorId sdk.RivetId, + request *sdk.ActorsDeleteRequest, + opts ...option.RequestOption, +) (sdk.ActorsDeleteResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := fmt.Sprintf(baseURL+"/"+"actors/%v", actorId) queryParams := make(url.Values) @@ -276,14 +338,18 @@ func (c *Client) ActorsDelete(ctx context.Context, actorId sdk.RivetId, request endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response sdk.ActorsDeleteResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodDelete, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodDelete, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err @@ -291,11 +357,22 @@ func (c *Client) ActorsDelete(ctx context.Context, actorId sdk.RivetId, request return response, nil } -func (c *Client) ActorsKvGet(ctx context.Context, actorId sdk.RivetId, key string, request *sdk.ActorsKvGetRequest) (*sdk.ActorsKvGetResponse, error) { +func (c *Client) ActorsKvGet( + ctx context.Context, + actorId sdk.RivetId, + key string, + request *sdk.ActorsKvGetRequest, + opts ...option.RequestOption, +) (*sdk.ActorsKvGetResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := fmt.Sprintf(baseURL+"/"+"actors/%v/kv/keys/%v", actorId, key) queryParams := make(url.Values) @@ -304,14 +381,18 @@ func (c *Client) ActorsKvGet(ctx context.Context, actorId sdk.RivetId, key strin endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.ActorsKvGetResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err @@ -319,11 +400,21 @@ func (c *Client) ActorsKvGet(ctx context.Context, actorId sdk.RivetId, key strin return response, nil } -func (c *Client) ActorsReschedule(ctx context.Context, actorId sdk.RivetId, request *sdk.ActorsRescheduleRequest) (sdk.ActorsRescheduleResponse, error) { +func (c *Client) ActorsReschedule( + ctx context.Context, + actorId sdk.RivetId, + request *sdk.ActorsRescheduleRequest, + opts ...option.RequestOption, +) (sdk.ActorsRescheduleResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := fmt.Sprintf(baseURL+"/"+"actors/%v/reschedule", actorId) queryParams := make(url.Values) @@ -332,15 +423,19 @@ func (c *Client) ActorsReschedule(ctx context.Context, actorId sdk.RivetId, requ endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response sdk.ActorsRescheduleResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodPost, - Headers: c.header, - Request: request, - Response: &response, + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, }, ); err != nil { return nil, err @@ -348,11 +443,21 @@ func (c *Client) ActorsReschedule(ctx context.Context, actorId sdk.RivetId, requ return response, nil } -func (c *Client) ActorsSleep(ctx context.Context, actorId sdk.RivetId, request *sdk.ActorsSleepRequest) (sdk.ActorsSleepResponse, error) { +func (c *Client) ActorsSleep( + ctx context.Context, + actorId sdk.RivetId, + request *sdk.ActorsSleepRequest, + opts ...option.RequestOption, +) (sdk.ActorsSleepResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := fmt.Sprintf(baseURL+"/"+"actors/%v/sleep", actorId) queryParams := make(url.Values) @@ -361,15 +466,19 @@ func (c *Client) ActorsSleep(ctx context.Context, actorId sdk.RivetId, request * endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response sdk.ActorsSleepResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodPost, - Headers: c.header, - Request: request, - Response: &response, + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, }, ); err != nil { return nil, err @@ -377,11 +486,20 @@ func (c *Client) ActorsSleep(ctx context.Context, actorId sdk.RivetId, request * return response, nil } -func (c *Client) RunnerConfigsList(ctx context.Context, request *sdk.RunnerConfigsListRequest) (*sdk.RunnerConfigsListResponse, error) { +func (c *Client) RunnerConfigsList( + ctx context.Context, + request *sdk.RunnerConfigsListRequest, + opts ...option.RequestOption, +) (*sdk.RunnerConfigsListResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "runner-configs" queryParams := make(url.Values) @@ -405,14 +523,18 @@ func (c *Client) RunnerConfigsList(ctx context.Context, request *sdk.RunnerConfi endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.RunnerConfigsListResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err @@ -420,11 +542,20 @@ func (c *Client) RunnerConfigsList(ctx context.Context, request *sdk.RunnerConfi return response, nil } -func (c *Client) RunnerConfigsServerlessHealthCheck(ctx context.Context, request *sdk.RunnerConfigsServerlessHealthCheckRequest) (*sdk.RunnerConfigsServerlessHealthCheckResponse, error) { +func (c *Client) RunnerConfigsServerlessHealthCheck( + ctx context.Context, + request *sdk.RunnerConfigsServerlessHealthCheckRequest, + opts ...option.RequestOption, +) (*sdk.RunnerConfigsServerlessHealthCheckResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "runner-configs/serverless-health-check" queryParams := make(url.Values) @@ -433,15 +564,19 @@ func (c *Client) RunnerConfigsServerlessHealthCheck(ctx context.Context, request endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.RunnerConfigsServerlessHealthCheckResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodPost, - Headers: c.header, - Request: request, - Response: &response, + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, }, ); err != nil { return nil, err @@ -449,11 +584,21 @@ func (c *Client) RunnerConfigsServerlessHealthCheck(ctx context.Context, request return response, nil } -func (c *Client) RunnerConfigsUpsert(ctx context.Context, runnerName string, request *sdk.RunnerConfigsUpsertRequestBody) (*sdk.RunnerConfigsUpsertResponse, error) { +func (c *Client) RunnerConfigsUpsert( + ctx context.Context, + runnerName string, + request *sdk.RunnerConfigsUpsertRequestBody, + opts ...option.RequestOption, +) (*sdk.RunnerConfigsUpsertResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := fmt.Sprintf(baseURL+"/"+"runner-configs/%v", runnerName) queryParams := make(url.Values) @@ -462,15 +607,19 @@ func (c *Client) RunnerConfigsUpsert(ctx context.Context, runnerName string, req endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.RunnerConfigsUpsertResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodPut, - Headers: c.header, - Request: request, - Response: &response, + URL: endpointURL, + Method: http.MethodPut, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, }, ); err != nil { return nil, err @@ -478,11 +627,21 @@ func (c *Client) RunnerConfigsUpsert(ctx context.Context, runnerName string, req return response, nil } -func (c *Client) RunnerConfigsDelete(ctx context.Context, runnerName string, request *sdk.RunnerConfigsDeleteRequest) (sdk.RunnerConfigsDeleteResponse, error) { +func (c *Client) RunnerConfigsDelete( + ctx context.Context, + runnerName string, + request *sdk.RunnerConfigsDeleteRequest, + opts ...option.RequestOption, +) (sdk.RunnerConfigsDeleteResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := fmt.Sprintf(baseURL+"/"+"runner-configs/%v", runnerName) queryParams := make(url.Values) @@ -491,14 +650,18 @@ func (c *Client) RunnerConfigsDelete(ctx context.Context, runnerName string, req endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response sdk.RunnerConfigsDeleteResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodDelete, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodDelete, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err @@ -506,11 +669,21 @@ func (c *Client) RunnerConfigsDelete(ctx context.Context, runnerName string, req return response, nil } -func (c *Client) RunnerConfigsRefreshMetadata(ctx context.Context, runnerName string, request *sdk.RunnerConfigsRefreshMetadataRequest) (sdk.RunnerConfigsRefreshMetadataResponse, error) { +func (c *Client) RunnerConfigsRefreshMetadata( + ctx context.Context, + runnerName string, + request *sdk.RunnerConfigsRefreshMetadataRequest, + opts ...option.RequestOption, +) (sdk.RunnerConfigsRefreshMetadataResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := fmt.Sprintf(baseURL+"/"+"runner-configs/%v/refresh-metadata", runnerName) queryParams := make(url.Values) @@ -519,15 +692,19 @@ func (c *Client) RunnerConfigsRefreshMetadata(ctx context.Context, runnerName st endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response sdk.RunnerConfigsRefreshMetadataResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodPost, - Headers: c.header, - Request: request, - Response: &response, + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, }, ); err != nil { return nil, err diff --git a/engine/sdks/go/api-full/client/client_test.go b/engine/sdks/go/api-full/client/client_test.go index a0fa36e4c7..453ea9e3ab 100644 --- a/engine/sdks/go/api-full/client/client_test.go +++ b/engine/sdks/go/api-full/client/client_test.go @@ -1,11 +1,13 @@ +// This file was auto-generated by Fern from our API Definition. + package client import ( - "net/http" - "testing" - "time" - - "github.com/stretchr/testify/assert" + assert "github.com/stretchr/testify/assert" + http "net/http" + option "sdk/option" + testing "testing" + time "time" ) func TestNewClient(t *testing.T) { @@ -16,7 +18,7 @@ func TestNewClient(t *testing.T) { t.Run("base url", func(t *testing.T) { c := NewClient( - WithBaseURL("test.co"), + option.WithBaseURL("test.co"), ) assert.Equal(t, "test.co", c.baseURL) }) @@ -26,7 +28,7 @@ func TestNewClient(t *testing.T) { Timeout: 5 * time.Second, } c := NewClient( - WithHTTPClient(httpClient), + option.WithHTTPClient(httpClient), ) assert.Empty(t, c.baseURL) }) @@ -35,7 +37,7 @@ func TestNewClient(t *testing.T) { header := make(http.Header) header.Set("X-API-Tenancy", "test") c := NewClient( - WithHTTPHeader(header), + option.WithHTTPHeader(header), ) assert.Empty(t, c.baseURL) assert.Equal(t, "test", c.header.Get("X-API-Tenancy")) diff --git a/engine/sdks/go/api-full/client/options.go b/engine/sdks/go/api-full/client/options.go index c9de12b7fa..87d425e4ef 100644 --- a/engine/sdks/go/api-full/client/options.go +++ b/engine/sdks/go/api-full/client/options.go @@ -5,35 +5,31 @@ package client import ( http "net/http" core "sdk/core" + option "sdk/option" ) -// WithBaseURL sets the client's base URL, overriding the -// default environment, if any. -func WithBaseURL(baseURL string) core.ClientOption { - return func(opts *core.ClientOptions) { - opts.BaseURL = baseURL - } +// WithBaseURL sets the base URL, overriding the default +// environment, if any. +func WithBaseURL(baseURL string) *core.BaseURLOption { + return option.WithBaseURL(baseURL) } -// WithHTTPClient uses the given HTTPClient to issue all HTTP requests. -func WithHTTPClient(httpClient core.HTTPClient) core.ClientOption { - return func(opts *core.ClientOptions) { - opts.HTTPClient = httpClient - } +// WithHTTPClient uses the given HTTPClient to issue the request. +func WithHTTPClient(httpClient core.HTTPClient) *core.HTTPClientOption { + return option.WithHTTPClient(httpClient) } -// WithHTTPHeader adds the given http.Header to all requests -// issued by the client. -func WithHTTPHeader(httpHeader http.Header) core.ClientOption { - return func(opts *core.ClientOptions) { - // Clone the headers so they can't be modified after the option call. - opts.HTTPHeader = httpHeader.Clone() - } +// WithHTTPHeader adds the given http.Header to the request. +func WithHTTPHeader(httpHeader http.Header) *core.HTTPHeaderOption { + return option.WithHTTPHeader(httpHeader) } -// WithToken sets the 'Authorization: Bearer ' header on every request. -func WithToken(token string) core.ClientOption { - return func(opts *core.ClientOptions) { - opts.Token = token - } +// WithMaxAttempts configures the maximum number of retry attempts. +func WithMaxAttempts(attempts uint) *core.MaxAttemptsOption { + return option.WithMaxAttempts(attempts) +} + +// WithToken sets the 'Authorization: Bearer ' request header. +func WithToken(token string) *core.TokenOption { + return option.WithToken(token) } diff --git a/engine/sdks/go/api-full/core/client_option.go b/engine/sdks/go/api-full/core/client_option.go deleted file mode 100644 index 9ef94ca125..0000000000 --- a/engine/sdks/go/api-full/core/client_option.go +++ /dev/null @@ -1,44 +0,0 @@ -// This file was auto-generated by Fern from our API Definition. - -package core - -import ( - http "net/http" -) - -// ClientOption adapts the behavior of the generated client. -type ClientOption func(*ClientOptions) - -// ClientOptions defines all of the possible client options. -// This type is primarily used by the generated code and is -// not meant to be used directly; use ClientOption instead. -type ClientOptions struct { - BaseURL string - HTTPClient HTTPClient - HTTPHeader http.Header - Token string -} - -// NewClientOptions returns a new *ClientOptions value. -// This function is primarily used by the generated code and is -// not meant to be used directly; use ClientOption instead. -func NewClientOptions() *ClientOptions { - return &ClientOptions{ - HTTPClient: http.DefaultClient, - HTTPHeader: make(http.Header), - } -} - -// ToHeader maps the configured client options into a http.Header issued -// on every request. -func (c *ClientOptions) ToHeader() http.Header { - header := c.cloneHeader() - if c.Token != "" { - header.Set("Authorization", "Bearer "+c.Token) - } - return header -} - -func (c *ClientOptions) cloneHeader() http.Header { - return c.HTTPHeader.Clone() -} diff --git a/engine/sdks/go/api-full/core/core.go b/engine/sdks/go/api-full/core/core.go index 10e757f2b3..5277d138d2 100644 --- a/engine/sdks/go/api-full/core/core.go +++ b/engine/sdks/go/api-full/core/core.go @@ -22,6 +22,21 @@ type HTTPClient interface { Do(*http.Request) (*http.Response, error) } +// MergeHeaders merges the given headers together, where the right +// takes precedence over the left. +func MergeHeaders(left, right http.Header) http.Header { + for key, values := range right { + if len(values) > 1 { + left[key] = values + continue + } + if value := right.Get(key); value != "" { + left.Set(key, value) + } + } + return left +} + // WriteMultipartJSON writes the given value as a JSON part. // This is used to serialize non-primitive multipart properties // (i.e. lists, objects, etc). @@ -78,13 +93,29 @@ type ErrorDecoder func(statusCode int, body io.Reader) error // Caller calls APIs and deserializes their response, if any. type Caller struct { - client HTTPClient + client HTTPClient + retrier *Retrier +} + +// CallerParams represents the parameters used to constrcut a new *Caller. +type CallerParams struct { + Client HTTPClient + MaxAttempts uint } -// NewCaller returns a new *Caller backed by the given HTTP client. -func NewCaller(client HTTPClient) *Caller { +// NewCaller returns a new *Caller backed by the given parameters. +func NewCaller(params *CallerParams) *Caller { + var httpClient HTTPClient = http.DefaultClient + if params.Client != nil { + httpClient = params.Client + } + var retryOptions []RetryOption + if params.MaxAttempts > 0 { + retryOptions = append(retryOptions, WithMaxAttempts(params.MaxAttempts)) + } return &Caller{ - client: client, + client: httpClient, + retrier: NewRetrier(retryOptions...), } } @@ -92,7 +123,9 @@ func NewCaller(client HTTPClient) *Caller { type CallParams struct { URL string Method string + MaxAttempts uint Headers http.Header + Client HTTPClient Request interface{} Response interface{} ResponseIsOptional bool @@ -111,7 +144,23 @@ func (c *Caller) Call(ctx context.Context, params *CallParams) error { return err } - resp, err := c.client.Do(req) + client := c.client + if params.Client != nil { + // Use the HTTP client scoped to the request. + client = params.Client + } + + var retryOptions []RetryOption + if params.MaxAttempts > 0 { + retryOptions = append(retryOptions, WithMaxAttempts(params.MaxAttempts)) + } + + resp, err := c.retrier.Run( + client.Do, + req, + params.ErrorDecoder, + retryOptions..., + ) if err != nil { return err } diff --git a/engine/sdks/go/api-full/core/core_test.go b/engine/sdks/go/api-full/core/core_test.go index 70a59ed62e..dd35fe2826 100644 --- a/engine/sdks/go/api-full/core/core_test.go +++ b/engine/sdks/go/api-full/core/core_test.go @@ -113,7 +113,11 @@ func TestCall(t *testing.T) { server = newTestServer(t, test) client = server.Client() ) - caller := NewCaller(client) + caller := NewCaller( + &CallerParams{ + Client: client, + }, + ) var response *Response err := caller.Call( context.Background(), @@ -137,6 +141,68 @@ func TestCall(t *testing.T) { } } +func TestMergeHeaders(t *testing.T) { + t.Run("both empty", func(t *testing.T) { + merged := MergeHeaders(make(http.Header), make(http.Header)) + assert.Empty(t, merged) + }) + + t.Run("empty left", func(t *testing.T) { + left := make(http.Header) + + right := make(http.Header) + right.Set("X-API-Version", "0.0.1") + + merged := MergeHeaders(left, right) + assert.Equal(t, "0.0.1", merged.Get("X-API-Version")) + }) + + t.Run("empty right", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Version", "0.0.1") + + right := make(http.Header) + + merged := MergeHeaders(left, right) + assert.Equal(t, "0.0.1", merged.Get("X-API-Version")) + }) + + t.Run("single value override", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Version", "0.0.0") + + right := make(http.Header) + right.Set("X-API-Version", "0.0.1") + + merged := MergeHeaders(left, right) + assert.Equal(t, []string{"0.0.1"}, merged.Values("X-API-Version")) + }) + + t.Run("multiple value override", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Versions", "0.0.0") + + right := make(http.Header) + right.Add("X-API-Versions", "0.0.1") + right.Add("X-API-Versions", "0.0.2") + + merged := MergeHeaders(left, right) + assert.Equal(t, []string{"0.0.1", "0.0.2"}, merged.Values("X-API-Versions")) + }) + + t.Run("disjoint merge", func(t *testing.T) { + left := make(http.Header) + left.Set("X-API-Tenancy", "test") + + right := make(http.Header) + right.Set("X-API-Version", "0.0.1") + + merged := MergeHeaders(left, right) + assert.Equal(t, []string{"test"}, merged.Values("X-API-Tenancy")) + assert.Equal(t, []string{"0.0.1"}, merged.Values("X-API-Version")) + }) +} + // newTestServer returns a new *httptest.Server configured with the // given test parameters. func newTestServer(t *testing.T, tc *TestCase) *httptest.Server { diff --git a/engine/sdks/go/api-full/core/request_option.go b/engine/sdks/go/api-full/core/request_option.go new file mode 100644 index 0000000000..c299116de3 --- /dev/null +++ b/engine/sdks/go/api-full/core/request_option.go @@ -0,0 +1,97 @@ +// This file was auto-generated by Fern from our API Definition. + +package core + +import ( + http "net/http" +) + +// RequestOption adapts the behavior of the client or an individual request. +type RequestOption interface { + applyRequestOptions(*RequestOptions) +} + +// RequestOptions defines all of the possible request options. +// +// This type is primarily used by the generated code and is not meant +// to be used directly; use the option package instead. +type RequestOptions struct { + BaseURL string + HTTPClient HTTPClient + HTTPHeader http.Header + MaxAttempts uint + Token string +} + +// NewRequestOptions returns a new *RequestOptions value. +// +// This function is primarily used by the generated code and is not meant +// to be used directly; use RequestOption instead. +func NewRequestOptions(opts ...RequestOption) *RequestOptions { + options := &RequestOptions{ + HTTPHeader: make(http.Header), + } + for _, opt := range opts { + opt.applyRequestOptions(options) + } + return options +} + +// ToHeader maps the configured request options into a http.Header used +// for the request(s). +func (r *RequestOptions) ToHeader() http.Header { + header := r.cloneHeader() + if r.Token != "" { + header.Set("Authorization", "Bearer "+r.Token) + } + return header +} + +func (r *RequestOptions) cloneHeader() http.Header { + return r.HTTPHeader.Clone() +} + +// BaseURLOption implements the RequestOption interface. +type BaseURLOption struct { + BaseURL string +} + +func (b *BaseURLOption) applyRequestOptions(opts *RequestOptions) { + opts.BaseURL = b.BaseURL +} + +// HTTPClientOption implements the RequestOption interface. +type HTTPClientOption struct { + HTTPClient HTTPClient +} + +func (h *HTTPClientOption) applyRequestOptions(opts *RequestOptions) { + opts.HTTPClient = h.HTTPClient +} + +// HTTPHeaderOption implements the RequestOption interface. +type HTTPHeaderOption struct { + HTTPHeader http.Header +} + +func (h *HTTPHeaderOption) applyRequestOptions(opts *RequestOptions) { + opts.HTTPHeader = h.HTTPHeader +} + +// MaxAttemptsOption implements the RequestOption interface. +type MaxAttemptsOption struct { + MaxAttempts uint +} + +func (m *MaxAttemptsOption) applyRequestOptions(opts *RequestOptions) { + opts.MaxAttempts = m.MaxAttempts +} + +// TokenOption implements the RequestOption interface. +type TokenOption struct { + Token string +} + +func (t *TokenOption) applyRequestOptions(opts *RequestOptions) { + opts.Token = t.Token +} diff --git a/engine/sdks/go/api-full/core/retrier.go b/engine/sdks/go/api-full/core/retrier.go new file mode 100644 index 0000000000..ea24916b78 --- /dev/null +++ b/engine/sdks/go/api-full/core/retrier.go @@ -0,0 +1,166 @@ +package core + +import ( + "crypto/rand" + "math/big" + "net/http" + "time" +) + +const ( + defaultRetryAttempts = 2 + minRetryDelay = 500 * time.Millisecond + maxRetryDelay = 5000 * time.Millisecond +) + +// RetryOption adapts the behavior the *Retrier. +type RetryOption func(*retryOptions) + +// RetryFunc is a retriable HTTP function call (i.e. *http.Client.Do). +type RetryFunc func(*http.Request) (*http.Response, error) + +// WithMaxAttempts configures the maximum number of attempts +// of the *Retrier. +func WithMaxAttempts(attempts uint) RetryOption { + return func(opts *retryOptions) { + opts.attempts = attempts + } +} + +// Retrier retries failed requests a configurable number of times with an +// exponential back-off between each retry. +type Retrier struct { + attempts uint +} + +// NewRetrier constructs a new *Retrier with the given options, if any. +func NewRetrier(opts ...RetryOption) *Retrier { + options := new(retryOptions) + for _, opt := range opts { + opt(options) + } + attempts := uint(defaultRetryAttempts) + if options.attempts > 0 { + attempts = options.attempts + } + return &Retrier{ + attempts: attempts, + } +} + +// Run issues the request and, upon failure, retries the request if possible. +// +// The request will be retried as long as the request is deemed retriable and the +// number of retry attempts has not grown larger than the configured retry limit. +func (r *Retrier) Run( + fn RetryFunc, + request *http.Request, + errorDecoder ErrorDecoder, + opts ...RetryOption, +) (*http.Response, error) { + options := new(retryOptions) + for _, opt := range opts { + opt(options) + } + maxRetryAttempts := r.attempts + if options.attempts > 0 { + maxRetryAttempts = options.attempts + } + var ( + retryAttempt uint + previousError error + ) + return r.run( + fn, + request, + errorDecoder, + maxRetryAttempts, + retryAttempt, + previousError, + ) +} + +func (r *Retrier) run( + fn RetryFunc, + request *http.Request, + errorDecoder ErrorDecoder, + maxRetryAttempts uint, + retryAttempt uint, + previousError error, +) (*http.Response, error) { + if retryAttempt >= maxRetryAttempts { + return nil, previousError + } + + // If the call has been cancelled, don't issue the request. + if err := request.Context().Err(); err != nil { + return nil, err + } + + response, err := fn(request) + if err != nil { + return nil, err + } + + if r.shouldRetry(response) { + defer response.Body.Close() + + delay, err := r.retryDelay(retryAttempt) + if err != nil { + return nil, err + } + + time.Sleep(delay) + + return r.run( + fn, + request, + errorDecoder, + maxRetryAttempts, + retryAttempt+1, + decodeError(response, errorDecoder), + ) + } + + return response, nil +} + +// shouldRetry returns true if the request should be retried based on the given +// response status code. +func (r *Retrier) shouldRetry(response *http.Response) bool { + return response.StatusCode == http.StatusTooManyRequests || + response.StatusCode == http.StatusRequestTimeout || + response.StatusCode == http.StatusConflict || + response.StatusCode >= http.StatusInternalServerError +} + +// retryDelay calculates the delay time in milliseconds based on the retry attempt. +func (r *Retrier) retryDelay(retryAttempt uint) (time.Duration, error) { + // Apply exponential backoff. + delay := minRetryDelay + minRetryDelay*time.Duration(retryAttempt*retryAttempt) + + // Do not allow the number to exceed maxRetryDelay. + if delay > maxRetryDelay { + delay = maxRetryDelay + } + + // Apply some itter by randomizing the value in the range of 75%-100%. + max := big.NewInt(int64(delay / 4)) + jitter, err := rand.Int(rand.Reader, max) + if err != nil { + return 0, err + } + + delay -= time.Duration(jitter.Int64()) + + // Never sleep less than the base sleep seconds. + if delay < minRetryDelay { + delay = minRetryDelay + } + + return delay, nil +} + +type retryOptions struct { + attempts uint +} diff --git a/engine/sdks/go/api-full/datacenters/client.go b/engine/sdks/go/api-full/datacenters/client.go index cf15a0ebf4..70c78408f2 100644 --- a/engine/sdks/go/api-full/datacenters/client.go +++ b/engine/sdks/go/api-full/datacenters/client.go @@ -7,6 +7,7 @@ import ( http "net/http" sdk "sdk" core "sdk/core" + option "sdk/option" ) type Client struct { @@ -15,33 +16,47 @@ type Client struct { header http.Header } -func NewClient(opts ...core.ClientOption) *Client { - options := core.NewClientOptions() - for _, opt := range opts { - opt(options) - } +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) return &Client{ baseURL: options.BaseURL, - caller: core.NewCaller(options.HTTPClient), - header: options.ToHeader(), + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), } } -func (c *Client) List(ctx context.Context) (*sdk.DatacentersListResponse, error) { +func (c *Client) List( + ctx context.Context, + opts ...option.RequestOption, +) (*sdk.DatacentersListResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "datacenters" + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.DatacentersListResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err diff --git a/engine/sdks/go/api-full/envoys/client.go b/engine/sdks/go/api-full/envoys/client.go index 3ff9d54e58..6375ac9c3b 100644 --- a/engine/sdks/go/api-full/envoys/client.go +++ b/engine/sdks/go/api-full/envoys/client.go @@ -9,6 +9,7 @@ import ( url "net/url" sdk "sdk" core "sdk/core" + option "sdk/option" ) type Client struct { @@ -17,23 +18,34 @@ type Client struct { header http.Header } -func NewClient(opts ...core.ClientOption) *Client { - options := core.NewClientOptions() - for _, opt := range opts { - opt(options) - } +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) return &Client{ baseURL: options.BaseURL, - caller: core.NewCaller(options.HTTPClient), - header: options.ToHeader(), + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), } } -func (c *Client) List(ctx context.Context, request *sdk.EnvoysListRequest) (*sdk.EnvoysListResponse, error) { +func (c *Client) List( + ctx context.Context, + request *sdk.EnvoysListRequest, + opts ...option.RequestOption, +) (*sdk.EnvoysListResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "envoys" queryParams := make(url.Values) @@ -54,14 +66,18 @@ func (c *Client) List(ctx context.Context, request *sdk.EnvoysListRequest) (*sdk endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.EnvoysListResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err diff --git a/engine/sdks/go/api-full/health/client.go b/engine/sdks/go/api-full/health/client.go index a079f00f09..82e2fc7831 100644 --- a/engine/sdks/go/api-full/health/client.go +++ b/engine/sdks/go/api-full/health/client.go @@ -7,6 +7,7 @@ import ( http "net/http" sdk "sdk" core "sdk/core" + option "sdk/option" ) type Client struct { @@ -15,33 +16,47 @@ type Client struct { header http.Header } -func NewClient(opts ...core.ClientOption) *Client { - options := core.NewClientOptions() - for _, opt := range opts { - opt(options) - } +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) return &Client{ baseURL: options.BaseURL, - caller: core.NewCaller(options.HTTPClient), - header: options.ToHeader(), + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), } } -func (c *Client) Fanout(ctx context.Context) (*sdk.HealthFanoutResponse, error) { +func (c *Client) Fanout( + ctx context.Context, + opts ...option.RequestOption, +) (*sdk.HealthFanoutResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "health/fanout" + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.HealthFanoutResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err diff --git a/engine/sdks/go/api-full/metadata/client.go b/engine/sdks/go/api-full/metadata/client.go index fff3fa766e..46b46088f7 100644 --- a/engine/sdks/go/api-full/metadata/client.go +++ b/engine/sdks/go/api-full/metadata/client.go @@ -7,6 +7,7 @@ import ( http "net/http" sdk "sdk" core "sdk/core" + option "sdk/option" ) type Client struct { @@ -15,33 +16,47 @@ type Client struct { header http.Header } -func NewClient(opts ...core.ClientOption) *Client { - options := core.NewClientOptions() - for _, opt := range opts { - opt(options) - } +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) return &Client{ baseURL: options.BaseURL, - caller: core.NewCaller(options.HTTPClient), - header: options.ToHeader(), + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), } } -func (c *Client) Get(ctx context.Context) (*sdk.MetadataGetResponse, error) { +func (c *Client) Get( + ctx context.Context, + opts ...option.RequestOption, +) (*sdk.MetadataGetResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "metadata" + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.MetadataGetResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err diff --git a/engine/sdks/go/api-full/namespaces/client.go b/engine/sdks/go/api-full/namespaces/client.go index 7057013ce1..0cd9c6b6fa 100644 --- a/engine/sdks/go/api-full/namespaces/client.go +++ b/engine/sdks/go/api-full/namespaces/client.go @@ -9,6 +9,7 @@ import ( url "net/url" sdk "sdk" core "sdk/core" + option "sdk/option" ) type Client struct { @@ -17,23 +18,34 @@ type Client struct { header http.Header } -func NewClient(opts ...core.ClientOption) *Client { - options := core.NewClientOptions() - for _, opt := range opts { - opt(options) - } +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) return &Client{ baseURL: options.BaseURL, - caller: core.NewCaller(options.HTTPClient), - header: options.ToHeader(), + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), } } -func (c *Client) List(ctx context.Context, request *sdk.NamespacesListRequest) (*sdk.NamespaceListResponse, error) { +func (c *Client) List( + ctx context.Context, + request *sdk.NamespacesListRequest, + opts ...option.RequestOption, +) (*sdk.NamespaceListResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "namespaces" queryParams := make(url.Values) @@ -56,14 +68,18 @@ func (c *Client) List(ctx context.Context, request *sdk.NamespacesListRequest) ( endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.NamespaceListResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err @@ -71,22 +87,35 @@ func (c *Client) List(ctx context.Context, request *sdk.NamespacesListRequest) ( return response, nil } -func (c *Client) Create(ctx context.Context, request *sdk.NamespacesCreateRequest) (*sdk.NamespacesCreateResponse, error) { +func (c *Client) Create( + ctx context.Context, + request *sdk.NamespacesCreateRequest, + opts ...option.RequestOption, +) (*sdk.NamespacesCreateResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "namespaces" + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.NamespacesCreateResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodPost, - Headers: c.header, - Request: request, - Response: &response, + URL: endpointURL, + Method: http.MethodPost, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Request: request, + Response: &response, }, ); err != nil { return nil, err diff --git a/engine/sdks/go/api-full/option/request_option.go b/engine/sdks/go/api-full/option/request_option.go new file mode 100644 index 0000000000..4857b71a73 --- /dev/null +++ b/engine/sdks/go/api-full/option/request_option.go @@ -0,0 +1,48 @@ +// This file was auto-generated by Fern from our API Definition. + +package option + +import ( + http "net/http" + core "sdk/core" +) + +// RequestOption adapts the behavior of an indivdual request. +type RequestOption = core.RequestOption + +// WithBaseURL sets the base URL, overriding the default +// environment, if any. +func WithBaseURL(baseURL string) *core.BaseURLOption { + return &core.BaseURLOption{ + BaseURL: baseURL, + } +} + +// WithHTTPClient uses the given HTTPClient to issue the request. +func WithHTTPClient(httpClient core.HTTPClient) *core.HTTPClientOption { + return &core.HTTPClientOption{ + HTTPClient: httpClient, + } +} + +// WithHTTPHeader adds the given http.Header to the request. +func WithHTTPHeader(httpHeader http.Header) *core.HTTPHeaderOption { + return &core.HTTPHeaderOption{ + // Clone the headers so they can't be modified after the option call. + HTTPHeader: httpHeader.Clone(), + } +} + +// WithMaxAttempts configures the maximum number of retry attempts. +func WithMaxAttempts(attempts uint) *core.MaxAttemptsOption { + return &core.MaxAttemptsOption{ + MaxAttempts: attempts, + } +} + +// WithToken sets the 'Authorization: Bearer ' request header. +func WithToken(token string) *core.TokenOption { + return &core.TokenOption{ + Token: token, + } +} diff --git a/engine/sdks/go/api-full/runners/client.go b/engine/sdks/go/api-full/runners/client.go index 9d61927840..31c8e34c8f 100644 --- a/engine/sdks/go/api-full/runners/client.go +++ b/engine/sdks/go/api-full/runners/client.go @@ -9,6 +9,7 @@ import ( url "net/url" sdk "sdk" core "sdk/core" + option "sdk/option" ) type Client struct { @@ -17,23 +18,34 @@ type Client struct { header http.Header } -func NewClient(opts ...core.ClientOption) *Client { - options := core.NewClientOptions() - for _, opt := range opts { - opt(options) - } +func NewClient(opts ...option.RequestOption) *Client { + options := core.NewRequestOptions(opts...) return &Client{ baseURL: options.BaseURL, - caller: core.NewCaller(options.HTTPClient), - header: options.ToHeader(), + caller: core.NewCaller( + &core.CallerParams{ + Client: options.HTTPClient, + MaxAttempts: options.MaxAttempts, + }, + ), + header: options.ToHeader(), } } -func (c *Client) List(ctx context.Context, request *sdk.RunnersListRequest) (*sdk.RunnersListResponse, error) { +func (c *Client) List( + ctx context.Context, + request *sdk.RunnersListRequest, + opts ...option.RequestOption, +) (*sdk.RunnersListResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "runners" queryParams := make(url.Values) @@ -60,14 +72,18 @@ func (c *Client) List(ctx context.Context, request *sdk.RunnersListRequest) (*sd endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.RunnersListResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err @@ -79,11 +95,20 @@ func (c *Client) List(ctx context.Context, request *sdk.RunnersListRequest) (*sd // // - GET /runners/names (fanout) // - [api-peer] namespace::ops::resolve_for_name_global -func (c *Client) ListNames(ctx context.Context, request *sdk.RunnersListNamesRequest) (*sdk.RunnersListNamesResponse, error) { +func (c *Client) ListNames( + ctx context.Context, + request *sdk.RunnersListNamesRequest, + opts ...option.RequestOption, +) (*sdk.RunnersListNamesResponse, error) { + options := core.NewRequestOptions(opts...) + baseURL := "" if c.baseURL != "" { baseURL = c.baseURL } + if options.BaseURL != "" { + baseURL = options.BaseURL + } endpointURL := baseURL + "/" + "runners/names" queryParams := make(url.Values) @@ -98,14 +123,18 @@ func (c *Client) ListNames(ctx context.Context, request *sdk.RunnersListNamesReq endpointURL += "?" + queryParams.Encode() } + headers := core.MergeHeaders(c.header.Clone(), options.ToHeader()) + var response *sdk.RunnersListNamesResponse if err := c.caller.Call( ctx, &core.CallParams{ - URL: endpointURL, - Method: http.MethodGet, - Headers: c.header, - Response: &response, + URL: endpointURL, + Method: http.MethodGet, + MaxAttempts: options.MaxAttempts, + Headers: headers, + Client: options.HTTPClient, + Response: &response, }, ); err != nil { return nil, err diff --git a/engine/sdks/go/api-full/types.go b/engine/sdks/go/api-full/types.go index 54ba338616..b07d0383ed 100644 --- a/engine/sdks/go/api-full/types.go +++ b/engine/sdks/go/api-full/types.go @@ -629,6 +629,168 @@ func (a *AuthTokenTargetScopeId) String() string { return fmt.Sprintf("%#v", a) } +type AuthWebhookJwks struct { + Keys []*AuthWebhookPublicKey `json:"keys,omitempty"` + + _rawJSON json.RawMessage +} + +func (a *AuthWebhookJwks) UnmarshalJSON(data []byte) error { + type unmarshaler AuthWebhookJwks + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *a = AuthWebhookJwks(value) + a._rawJSON = json.RawMessage(data) + return nil +} + +func (a *AuthWebhookJwks) String() string { + if len(a._rawJSON) > 0 { + if value, err := core.StringifyJSON(a._rawJSON); err == nil { + return value + } + } + if value, err := core.StringifyJSON(a); err == nil { + return value + } + return fmt.Sprintf("%#v", a) +} + +type AuthWebhookKeyStatus struct { + ActiveKid string `json:"active_kid"` + Generation int64 `json:"generation"` + LastEmergencyGeneration *int64 `json:"last_emergency_generation,omitempty"` + LastEmergencyRequestId *string `json:"last_emergency_request_id,omitempty"` + PendingKid *string `json:"pending_kid,omitempty"` + + _rawJSON json.RawMessage +} + +func (a *AuthWebhookKeyStatus) UnmarshalJSON(data []byte) error { + type unmarshaler AuthWebhookKeyStatus + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *a = AuthWebhookKeyStatus(value) + a._rawJSON = json.RawMessage(data) + return nil +} + +func (a *AuthWebhookKeyStatus) String() string { + if len(a._rawJSON) > 0 { + if value, err := core.StringifyJSON(a._rawJSON); err == nil { + return value + } + } + if value, err := core.StringifyJSON(a); err == nil { + return value + } + return fmt.Sprintf("%#v", a) +} + +type AuthWebhookMetadata struct { + Alg string `json:"alg"` + Generation int64 `json:"generation"` + JwksUrl string `json:"jwks_url"` + Kid string `json:"kid"` + NamespaceId RivetId `json:"namespace_id"` + + _rawJSON json.RawMessage +} + +func (a *AuthWebhookMetadata) UnmarshalJSON(data []byte) error { + type unmarshaler AuthWebhookMetadata + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *a = AuthWebhookMetadata(value) + a._rawJSON = json.RawMessage(data) + return nil +} + +func (a *AuthWebhookMetadata) String() string { + if len(a._rawJSON) > 0 { + if value, err := core.StringifyJSON(a._rawJSON); err == nil { + return value + } + } + if value, err := core.StringifyJSON(a); err == nil { + return value + } + return fmt.Sprintf("%#v", a) +} + +type AuthWebhookPublicKey struct { + Alg string `json:"alg"` + Crv string `json:"crv"` + Kid string `json:"kid"` + Kty string `json:"kty"` + Use string `json:"use"` + X string `json:"x"` + + _rawJSON json.RawMessage +} + +func (a *AuthWebhookPublicKey) UnmarshalJSON(data []byte) error { + type unmarshaler AuthWebhookPublicKey + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *a = AuthWebhookPublicKey(value) + a._rawJSON = json.RawMessage(data) + return nil +} + +func (a *AuthWebhookPublicKey) String() string { + if len(a._rawJSON) > 0 { + if value, err := core.StringifyJSON(a._rawJSON); err == nil { + return value + } + } + if value, err := core.StringifyJSON(a); err == nil { + return value + } + return fmt.Sprintf("%#v", a) +} + +type AuthWebhookSignResponse struct { + Kid string `json:"kid"` + // Unpadded base64url Ed25519 signature of timestamp + '.' + raw request body. + Signature string `json:"signature"` + // Canonical decimal Unix seconds, generated by the control plane. + Timestamp string `json:"timestamp"` + + _rawJSON json.RawMessage +} + +func (a *AuthWebhookSignResponse) UnmarshalJSON(data []byte) error { + type unmarshaler AuthWebhookSignResponse + var value unmarshaler + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *a = AuthWebhookSignResponse(value) + a._rawJSON = json.RawMessage(data) + return nil +} + +func (a *AuthWebhookSignResponse) String() string { + if len(a._rawJSON) > 0 { + if value, err := core.StringifyJSON(a._rawJSON); err == nil { + return value + } + } + if value, err := core.StringifyJSON(a); err == nil { + return value + } + return fmt.Sprintf("%#v", a) +} + type CrashPolicy string const ( diff --git a/engine/sdks/rust/api-full/.openapi-generator/FILES b/engine/sdks/rust/api-full/.openapi-generator/FILES index cae35eb15d..4b20fa7dd9 100644 --- a/engine/sdks/rust/api-full/.openapi-generator/FILES +++ b/engine/sdks/rust/api-full/.openapi-generator/FILES @@ -86,7 +86,13 @@ src/apis/actors_list_api.rs src/apis/actors_list_names_api.rs src/apis/actors_reschedule_api.rs src/apis/actors_sleep_api.rs +src/apis/auth_application_tokens_api.rs +src/apis/auth_issuers_api.rs +src/apis/auth_jwks_api.rs src/apis/auth_tokens_api.rs +src/apis/auth_webhook_discovery_api.rs +src/apis/auth_webhook_keys_api.rs +src/apis/auth_webhook_signing_api.rs src/apis/configuration.rs src/apis/datacenters_api.rs src/apis/envoys_api.rs diff --git a/engine/sdks/rust/api-full/README.md b/engine/sdks/rust/api-full/README.md index 0ad22508f4..4e3af990cc 100644 --- a/engine/sdks/rust/api-full/README.md +++ b/engine/sdks/rust/api-full/README.md @@ -34,8 +34,21 @@ Class | Method | HTTP request | Description *ActorsListNamesApi* | [**actors_list_names**](docs/ActorsListNamesApi.md#actors_list_names) | **GET** /actors/names | ## Datacenter Round Trips *ActorsRescheduleApi* | [**actors_reschedule**](docs/ActorsRescheduleApi.md#actors_reschedule) | **POST** /actors/{actor_id}/reschedule | *ActorsSleepApi* | [**actors_sleep**](docs/ActorsSleepApi.md#actors_sleep) | **POST** /actors/{actor_id}/sleep | +*AuthApplicationTokensApi* | [**auth_application_tokens_create**](docs/AuthApplicationTokensApi.md#auth_application_tokens_create) | **POST** /auth/application-tokens | +*AuthIssuersApi* | [**auth_issuers_delete**](docs/AuthIssuersApi.md#auth_issuers_delete) | **DELETE** /namespaces/{namespace}/auth/issuers/{issuer_name} | +*AuthIssuersApi* | [**auth_issuers_get**](docs/AuthIssuersApi.md#auth_issuers_get) | **GET** /namespaces/{namespace}/auth/issuers/{issuer_name} | +*AuthIssuersApi* | [**auth_issuers_list**](docs/AuthIssuersApi.md#auth_issuers_list) | **GET** /namespaces/{namespace}/auth/issuers | +*AuthIssuersApi* | [**auth_issuers_upsert**](docs/AuthIssuersApi.md#auth_issuers_upsert) | **PUT** /namespaces/{namespace}/auth/issuers/{issuer_name} | +*AuthJwksApi* | [**auth_jwks_get**](docs/AuthJwksApi.md#auth_jwks_get) | **GET** /.well-known/jwks.json | Publishes only the public halves of Rivet's currently valid signing keys. *AuthTokensApi* | [**auth_tokens_create**](docs/AuthTokensApi.md#auth_tokens_create) | **POST** /auth/tokens | *AuthTokensApi* | [**auth_tokens_inspect**](docs/AuthTokensApi.md#auth_tokens_inspect) | **GET** /auth/tokens/inspect | +*AuthWebhookDiscoveryApi* | [**auth_webhooks_jwks**](docs/AuthWebhookDiscoveryApi.md#auth_webhooks_jwks) | **GET** /namespaces/{namespace}/auth/webhooks/jwks.json | +*AuthWebhookDiscoveryApi* | [**auth_webhooks_metadata**](docs/AuthWebhookDiscoveryApi.md#auth_webhooks_metadata) | **GET** /namespaces/{namespace}/auth/webhooks | +*AuthWebhookKeysApi* | [**auth_webhook_keys_get**](docs/AuthWebhookKeysApi.md#auth_webhook_keys_get) | **GET** /namespaces/{namespace}/auth/webhook-keys | +*AuthWebhookKeysApi* | [**auth_webhook_keys_revoke**](docs/AuthWebhookKeysApi.md#auth_webhook_keys_revoke) | **POST** /namespaces/{namespace}/auth/webhook-keys/revoke | +*AuthWebhookKeysApi* | [**auth_webhook_keys_rotate**](docs/AuthWebhookKeysApi.md#auth_webhook_keys_rotate) | **POST** /namespaces/{namespace}/auth/webhook-keys/rotate | +*AuthWebhookKeysApi* | [**auth_webhooks_ensure**](docs/AuthWebhookKeysApi.md#auth_webhooks_ensure) | **POST** /auth/webhooks/ensure | +*AuthWebhookSigningApi* | [**auth_webhooks_sign**](docs/AuthWebhookSigningApi.md#auth_webhooks_sign) | **POST** /auth/webhooks/sign | *DatacentersApi* | [**datacenters_list**](docs/DatacentersApi.md#datacenters_list) | **GET** /datacenters | *EnvoysApi* | [**envoys_list**](docs/EnvoysApi.md#envoys_list) | **GET** /envoys | *HealthApi* | [**health_fanout**](docs/HealthApi.md#health_fanout) | **GET** /health/fanout | diff --git a/engine/sdks/rust/api-full/docs/ActorName.md b/engine/sdks/rust/api-full/docs/ActorName.md index 918ef8b0b7..efd66ba1d8 100644 --- a/engine/sdks/rust/api-full/docs/ActorName.md +++ b/engine/sdks/rust/api-full/docs/ActorName.md @@ -4,7 +4,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**metadata** | [**serde_json::Value**](.md) | | +**metadata** | [**std::collections::HashMap**](serde_json::Value.md) | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/engine/sdks/rust/api-full/docs/AuthApplicationTokenCreateRequest.md b/engine/sdks/rust/api-full/docs/AuthApplicationTokenCreateRequest.md new file mode 100644 index 0000000000..4ea8a1c388 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthApplicationTokenCreateRequest.md @@ -0,0 +1,15 @@ +# AuthApplicationTokenCreateRequest + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**audience** | **String** | | +**claims** | [**std::collections::HashMap**](serde_json::Value.md) | | +**duration** | Option<**i64**> | | [optional] +**purpose** | **String** | | +**subject** | Option<**String**> | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthApplicationTokenCreateResponse.md b/engine/sdks/rust/api-full/docs/AuthApplicationTokenCreateResponse.md new file mode 100644 index 0000000000..3f397a8daf --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthApplicationTokenCreateResponse.md @@ -0,0 +1,13 @@ +# AuthApplicationTokenCreateResponse + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**expires_ts** | **i64** | | +**issued_ts** | **i64** | | +**token** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthApplicationTokensApi.md b/engine/sdks/rust/api-full/docs/AuthApplicationTokensApi.md new file mode 100644 index 0000000000..b9315f4e28 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthApplicationTokensApi.md @@ -0,0 +1,37 @@ +# \AuthApplicationTokensApi + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**auth_application_tokens_create**](AuthApplicationTokensApi.md#auth_application_tokens_create) | **POST** /auth/application-tokens | + + + +## auth_application_tokens_create + +> models::AuthApplicationTokenCreateResponse auth_application_tokens_create(auth_application_token_create_request) + + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**auth_application_token_create_request** | [**AuthApplicationTokenCreateRequest**](AuthApplicationTokenCreateRequest.md) | | [required] | + +### Return type + +[**models::AuthApplicationTokenCreateResponse**](AuthApplicationTokenCreateResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/engine/sdks/rust/api-full/docs/AuthIssuer.md b/engine/sdks/rust/api-full/docs/AuthIssuer.md new file mode 100644 index 0000000000..a5b6dd1fef --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthIssuer.md @@ -0,0 +1,16 @@ +# AuthIssuer + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**algorithm** | **String** | | +**audience** | **String** | | +**grant_ceiling** | [**Vec**](AuthIssuerGrant.md) | | +**issuer** | **String** | | +**jwks_url** | **String** | | +**name** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthIssuerGetResponse.md b/engine/sdks/rust/api-full/docs/AuthIssuerGetResponse.md new file mode 100644 index 0000000000..f71dce50d3 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthIssuerGetResponse.md @@ -0,0 +1,11 @@ +# AuthIssuerGetResponse + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**issuer** | [**models::AuthIssuer**](AuthIssuer.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthIssuerGrant.md b/engine/sdks/rust/api-full/docs/AuthIssuerGrant.md new file mode 100644 index 0000000000..7647683a73 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthIssuerGrant.md @@ -0,0 +1,13 @@ +# AuthIssuerGrant + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**operations** | [**Vec**](AclOperationKindKind.md) | | +**resource** | [**models::AclResourceKind**](AclResourceKind.md) | | +**target** | [**models::AclTargetScope**](AclTargetScope.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthIssuerListResponse.md b/engine/sdks/rust/api-full/docs/AuthIssuerListResponse.md new file mode 100644 index 0000000000..d74038a765 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthIssuerListResponse.md @@ -0,0 +1,11 @@ +# AuthIssuerListResponse + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**issuers** | [**Vec**](AuthIssuer.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthIssuerUpsertRequest.md b/engine/sdks/rust/api-full/docs/AuthIssuerUpsertRequest.md new file mode 100644 index 0000000000..3e20e55957 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthIssuerUpsertRequest.md @@ -0,0 +1,15 @@ +# AuthIssuerUpsertRequest + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**algorithm** | **String** | Pinned signature algorithm: EdDSA, RS256, or ES256. | +**audience** | **String** | Required, exact `aud` claim for Rivet (never optional). | +**grant_ceiling** | [**Vec**](AuthIssuerGrant.md) | Maximum access this issuer may embed in a signed Rivet JWT. | +**issuer** | **String** | Exact `iss` claim to trust for this namespace. | +**jwks_url** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthIssuerUpsertResponse.md b/engine/sdks/rust/api-full/docs/AuthIssuerUpsertResponse.md new file mode 100644 index 0000000000..4f7d849c46 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthIssuerUpsertResponse.md @@ -0,0 +1,11 @@ +# AuthIssuerUpsertResponse + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**issuer** | [**models::AuthIssuer**](AuthIssuer.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthIssuersApi.md b/engine/sdks/rust/api-full/docs/AuthIssuersApi.md new file mode 100644 index 0000000000..c501585df0 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthIssuersApi.md @@ -0,0 +1,128 @@ +# \AuthIssuersApi + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**auth_issuers_delete**](AuthIssuersApi.md#auth_issuers_delete) | **DELETE** /namespaces/{namespace}/auth/issuers/{issuer_name} | +[**auth_issuers_get**](AuthIssuersApi.md#auth_issuers_get) | **GET** /namespaces/{namespace}/auth/issuers/{issuer_name} | +[**auth_issuers_list**](AuthIssuersApi.md#auth_issuers_list) | **GET** /namespaces/{namespace}/auth/issuers | +[**auth_issuers_upsert**](AuthIssuersApi.md#auth_issuers_upsert) | **PUT** /namespaces/{namespace}/auth/issuers/{issuer_name} | + + + +## auth_issuers_delete + +> serde_json::Value auth_issuers_delete(namespace, issuer_name) + + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**namespace** | **String** | Namespace name | [required] | +**issuer_name** | **String** | Issuer registration name | [required] | + +### Return type + +[**serde_json::Value**](serde_json::Value.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + +## auth_issuers_get + +> models::AuthIssuerGetResponse auth_issuers_get(namespace, issuer_name) + + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**namespace** | **String** | Namespace name | [required] | +**issuer_name** | **String** | Issuer registration name | [required] | + +### Return type + +[**models::AuthIssuerGetResponse**](AuthIssuerGetResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + +## auth_issuers_list + +> models::AuthIssuerListResponse auth_issuers_list(namespace) + + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**namespace** | **String** | Namespace name | [required] | + +### Return type + +[**models::AuthIssuerListResponse**](AuthIssuerListResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + +## auth_issuers_upsert + +> models::AuthIssuerUpsertResponse auth_issuers_upsert(namespace, issuer_name, auth_issuer_upsert_request) + + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**namespace** | **String** | Namespace name | [required] | +**issuer_name** | **String** | Issuer registration name | [required] | +**auth_issuer_upsert_request** | [**AuthIssuerUpsertRequest**](AuthIssuerUpsertRequest.md) | | [required] | + +### Return type + +[**models::AuthIssuerUpsertResponse**](AuthIssuerUpsertResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/engine/sdks/rust/api-full/docs/AuthJwksApi.md b/engine/sdks/rust/api-full/docs/AuthJwksApi.md new file mode 100644 index 0000000000..9e5b0a752e --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthJwksApi.md @@ -0,0 +1,34 @@ +# \AuthJwksApi + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**auth_jwks_get**](AuthJwksApi.md#auth_jwks_get) | **GET** /.well-known/jwks.json | Publishes only the public halves of Rivet's currently valid signing keys. + + + +## auth_jwks_get + +> serde_json::Value auth_jwks_get() +Publishes only the public halves of Rivet's currently valid signing keys. + +### Parameters + +This endpoint does not need any parameter. + +### Return type + +[**serde_json::Value**](serde_json::Value.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/engine/sdks/rust/api-full/docs/AuthWebhookDiscoveryApi.md b/engine/sdks/rust/api-full/docs/AuthWebhookDiscoveryApi.md new file mode 100644 index 0000000000..0ec03b3e2c --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthWebhookDiscoveryApi.md @@ -0,0 +1,66 @@ +# \AuthWebhookDiscoveryApi + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**auth_webhooks_jwks**](AuthWebhookDiscoveryApi.md#auth_webhooks_jwks) | **GET** /namespaces/{namespace}/auth/webhooks/jwks.json | +[**auth_webhooks_metadata**](AuthWebhookDiscoveryApi.md#auth_webhooks_metadata) | **GET** /namespaces/{namespace}/auth/webhooks | + + + +## auth_webhooks_jwks + +> models::AuthWebhookJwks auth_webhooks_jwks(namespace) + + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**namespace** | **String** | Immutable namespace ID | [required] | + +### Return type + +[**models::AuthWebhookJwks**](AuthWebhookJwks.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/jwk-set+json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + +## auth_webhooks_metadata + +> models::AuthWebhookMetadata auth_webhooks_metadata(namespace) + + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**namespace** | **String** | Immutable namespace ID | [required] | + +### Return type + +[**models::AuthWebhookMetadata**](AuthWebhookMetadata.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/engine/sdks/rust/api-full/docs/AuthWebhookJwks.md b/engine/sdks/rust/api-full/docs/AuthWebhookJwks.md new file mode 100644 index 0000000000..26f2715742 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthWebhookJwks.md @@ -0,0 +1,11 @@ +# AuthWebhookJwks + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**keys** | [**Vec**](AuthWebhookPublicKey.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthWebhookKeyStatus.md b/engine/sdks/rust/api-full/docs/AuthWebhookKeyStatus.md new file mode 100644 index 0000000000..082bc334d8 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthWebhookKeyStatus.md @@ -0,0 +1,15 @@ +# AuthWebhookKeyStatus + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**active_kid** | **String** | | +**generation** | **i64** | | +**last_emergency_generation** | Option<**i64**> | | [optional] +**last_emergency_request_id** | Option<**String**> | | [optional] +**pending_kid** | Option<**String**> | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthWebhookKeysApi.md b/engine/sdks/rust/api-full/docs/AuthWebhookKeysApi.md new file mode 100644 index 0000000000..89675ac789 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthWebhookKeysApi.md @@ -0,0 +1,123 @@ +# \AuthWebhookKeysApi + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**auth_webhook_keys_get**](AuthWebhookKeysApi.md#auth_webhook_keys_get) | **GET** /namespaces/{namespace}/auth/webhook-keys | +[**auth_webhook_keys_revoke**](AuthWebhookKeysApi.md#auth_webhook_keys_revoke) | **POST** /namespaces/{namespace}/auth/webhook-keys/revoke | +[**auth_webhook_keys_rotate**](AuthWebhookKeysApi.md#auth_webhook_keys_rotate) | **POST** /namespaces/{namespace}/auth/webhook-keys/rotate | +[**auth_webhooks_ensure**](AuthWebhookKeysApi.md#auth_webhooks_ensure) | **POST** /auth/webhooks/ensure | + + + +## auth_webhook_keys_get + +> models::AuthWebhookKeyStatus auth_webhook_keys_get(namespace) + + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**namespace** | **String** | Namespace name | [required] | + +### Return type + +[**models::AuthWebhookKeyStatus**](AuthWebhookKeyStatus.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + +## auth_webhook_keys_revoke + +> models::AuthWebhookKeyStatus auth_webhook_keys_revoke(namespace, auth_webhook_revoke_request) + + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**namespace** | **String** | Namespace name | [required] | +**auth_webhook_revoke_request** | [**AuthWebhookRevokeRequest**](AuthWebhookRevokeRequest.md) | | [required] | + +### Return type + +[**models::AuthWebhookKeyStatus**](AuthWebhookKeyStatus.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + +## auth_webhook_keys_rotate + +> models::AuthWebhookKeyStatus auth_webhook_keys_rotate(namespace, auth_webhook_rotate_request) + + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**namespace** | **String** | Namespace name | [required] | +**auth_webhook_rotate_request** | [**AuthWebhookRotateRequest**](AuthWebhookRotateRequest.md) | | [required] | + +### Return type + +[**models::AuthWebhookKeyStatus**](AuthWebhookKeyStatus.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + + +## auth_webhooks_ensure + +> models::AuthWebhookMetadata auth_webhooks_ensure() + + +### Parameters + +This endpoint does not need any parameter. + +### Return type + +[**models::AuthWebhookMetadata**](AuthWebhookMetadata.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/engine/sdks/rust/api-full/docs/AuthWebhookMetadata.md b/engine/sdks/rust/api-full/docs/AuthWebhookMetadata.md new file mode 100644 index 0000000000..924cb52b77 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthWebhookMetadata.md @@ -0,0 +1,15 @@ +# AuthWebhookMetadata + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**alg** | **String** | | +**generation** | **i64** | | +**jwks_url** | **String** | | +**kid** | **String** | | +**namespace_id** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthWebhookPublicKey.md b/engine/sdks/rust/api-full/docs/AuthWebhookPublicKey.md new file mode 100644 index 0000000000..5b96333547 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthWebhookPublicKey.md @@ -0,0 +1,16 @@ +# AuthWebhookPublicKey + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**alg** | **String** | | +**crv** | **String** | | +**kid** | **String** | | +**kty** | **String** | | +**r#use** | **String** | | +**x** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthWebhookRevokeRequest.md b/engine/sdks/rust/api-full/docs/AuthWebhookRevokeRequest.md new file mode 100644 index 0000000000..746782e73a --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthWebhookRevokeRequest.md @@ -0,0 +1,13 @@ +# AuthWebhookRevokeRequest + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**expected_generation** | **i64** | | +**kids** | **Vec** | | +**request_id** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthWebhookRotateRequest.md b/engine/sdks/rust/api-full/docs/AuthWebhookRotateRequest.md new file mode 100644 index 0000000000..60b86051e2 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthWebhookRotateRequest.md @@ -0,0 +1,11 @@ +# AuthWebhookRotateRequest + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**expected_generation** | **i64** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthWebhookSignResponse.md b/engine/sdks/rust/api-full/docs/AuthWebhookSignResponse.md new file mode 100644 index 0000000000..60d8313423 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthWebhookSignResponse.md @@ -0,0 +1,13 @@ +# AuthWebhookSignResponse + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**kid** | **String** | | +**signature** | **String** | Unpadded base64url Ed25519 signature of timestamp + '.' + raw request body. | +**timestamp** | **String** | Canonical decimal Unix seconds, generated by the control plane. | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/engine/sdks/rust/api-full/docs/AuthWebhookSigningApi.md b/engine/sdks/rust/api-full/docs/AuthWebhookSigningApi.md new file mode 100644 index 0000000000..2565ebd6c8 --- /dev/null +++ b/engine/sdks/rust/api-full/docs/AuthWebhookSigningApi.md @@ -0,0 +1,37 @@ +# \AuthWebhookSigningApi + +All URIs are relative to *http://localhost* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**auth_webhooks_sign**](AuthWebhookSigningApi.md#auth_webhooks_sign) | **POST** /auth/webhooks/sign | + + + +## auth_webhooks_sign + +> models::AuthWebhookSignResponse auth_webhooks_sign(body) + + +### Parameters + + +Name | Type | Description | Required | Notes +------------- | ------------- | ------------- | ------------- | ------------- +**body** | **Vec** | | [required] | + +### Return type + +[**models::AuthWebhookSignResponse**](AuthWebhookSignResponse.md) + +### Authorization + +[bearer_auth](../README.md#bearer_auth) + +### HTTP request headers + +- **Content-Type**: application/octet-stream +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/engine/sdks/rust/api-full/docs/Envoy.md b/engine/sdks/rust/api-full/docs/Envoy.md index 9be66a2b94..f7a7fef3c4 100644 --- a/engine/sdks/rust/api-full/docs/Envoy.md +++ b/engine/sdks/rust/api-full/docs/Envoy.md @@ -10,7 +10,7 @@ Name | Type | Description | Notes **last_connected_ts** | Option<**i64**> | | [optional] **last_ping_ts** | **i64** | | **last_rtt** | **i32** | | -**metadata** | Option<[**serde_json::Value**](serde_json::Value.md)> | | [optional] +**metadata** | Option<[**std::collections::HashMap**](serde_json::Value.md)> | | [optional] **namespace_id** | **String** | | **pool_name** | **String** | | **slots** | **i64** | | diff --git a/engine/sdks/rust/api-full/docs/Runner.md b/engine/sdks/rust/api-full/docs/Runner.md index f8a55e9674..c205154331 100644 --- a/engine/sdks/rust/api-full/docs/Runner.md +++ b/engine/sdks/rust/api-full/docs/Runner.md @@ -11,7 +11,7 @@ Name | Type | Description | Notes **last_connected_ts** | Option<**i64**> | | [optional] **last_ping_ts** | **i64** | | **last_rtt** | **i32** | | -**metadata** | Option<[**serde_json::Value**](serde_json::Value.md)> | | [optional] +**metadata** | Option<[**std::collections::HashMap**](serde_json::Value.md)> | | [optional] **name** | **String** | | **namespace_id** | **String** | | **remaining_slots** | **i32** | | diff --git a/engine/sdks/rust/api-full/src/apis/auth_application_tokens_api.rs b/engine/sdks/rust/api-full/src/apis/auth_application_tokens_api.rs new file mode 100644 index 0000000000..23b2512088 --- /dev/null +++ b/engine/sdks/rust/api-full/src/apis/auth_application_tokens_api.rs @@ -0,0 +1,70 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use super::{configuration, ContentType, Error}; +use crate::{apis::ResponseContent, models}; +use reqwest; +use serde::{de::Error as _, Deserialize, Serialize}; + +/// struct for typed errors of method [`auth_application_tokens_create`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthApplicationTokensCreateError { + UnknownValue(serde_json::Value), +} + +pub async fn auth_application_tokens_create( + configuration: &configuration::Configuration, + auth_application_token_create_request: models::AuthApplicationTokenCreateRequest, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_auth_application_token_create_request = auth_application_token_create_request; + + let uri_str = format!("{}/auth/application-tokens", configuration.base_path); + let mut req_builder = configuration + .client + .request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + req_builder = req_builder.json(&p_auth_application_token_create_request); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::AuthApplicationTokenCreateResponse`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::AuthApplicationTokenCreateResponse`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} diff --git a/engine/sdks/rust/api-full/src/apis/auth_issuers_api.rs b/engine/sdks/rust/api-full/src/apis/auth_issuers_api.rs new file mode 100644 index 0000000000..aa4468fe07 --- /dev/null +++ b/engine/sdks/rust/api-full/src/apis/auth_issuers_api.rs @@ -0,0 +1,256 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use super::{configuration, ContentType, Error}; +use crate::{apis::ResponseContent, models}; +use reqwest; +use serde::{de::Error as _, Deserialize, Serialize}; + +/// struct for typed errors of method [`auth_issuers_delete`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthIssuersDeleteError { + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`auth_issuers_get`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthIssuersGetError { + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`auth_issuers_list`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthIssuersListError { + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`auth_issuers_upsert`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthIssuersUpsertError { + UnknownValue(serde_json::Value), +} + +pub async fn auth_issuers_delete( + configuration: &configuration::Configuration, + namespace: &str, + issuer_name: &str, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_namespace = namespace; + let p_issuer_name = issuer_name; + + let uri_str = format!( + "{}/namespaces/{namespace}/auth/issuers/{issuer_name}", + configuration.base_path, + namespace = crate::apis::urlencode(p_namespace), + issuer_name = crate::apis::urlencode(p_issuer_name) + ); + let mut req_builder = configuration + .client + .request(reqwest::Method::DELETE, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `serde_json::Value`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `serde_json::Value`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + +pub async fn auth_issuers_get( + configuration: &configuration::Configuration, + namespace: &str, + issuer_name: &str, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_namespace = namespace; + let p_issuer_name = issuer_name; + + let uri_str = format!( + "{}/namespaces/{namespace}/auth/issuers/{issuer_name}", + configuration.base_path, + namespace = crate::apis::urlencode(p_namespace), + issuer_name = crate::apis::urlencode(p_issuer_name) + ); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::AuthIssuerGetResponse`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::AuthIssuerGetResponse`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + +pub async fn auth_issuers_list( + configuration: &configuration::Configuration, + namespace: &str, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_namespace = namespace; + + let uri_str = format!( + "{}/namespaces/{namespace}/auth/issuers", + configuration.base_path, + namespace = crate::apis::urlencode(p_namespace) + ); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::AuthIssuerListResponse`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::AuthIssuerListResponse`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + +pub async fn auth_issuers_upsert( + configuration: &configuration::Configuration, + namespace: &str, + issuer_name: &str, + auth_issuer_upsert_request: models::AuthIssuerUpsertRequest, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_namespace = namespace; + let p_issuer_name = issuer_name; + let p_auth_issuer_upsert_request = auth_issuer_upsert_request; + + let uri_str = format!( + "{}/namespaces/{namespace}/auth/issuers/{issuer_name}", + configuration.base_path, + namespace = crate::apis::urlencode(p_namespace), + issuer_name = crate::apis::urlencode(p_issuer_name) + ); + let mut req_builder = configuration.client.request(reqwest::Method::PUT, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + req_builder = req_builder.json(&p_auth_issuer_upsert_request); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::AuthIssuerUpsertResponse`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::AuthIssuerUpsertResponse`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} diff --git a/engine/sdks/rust/api-full/src/apis/auth_jwks_api.rs b/engine/sdks/rust/api-full/src/apis/auth_jwks_api.rs new file mode 100644 index 0000000000..fe3348a6af --- /dev/null +++ b/engine/sdks/rust/api-full/src/apis/auth_jwks_api.rs @@ -0,0 +1,63 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use super::{configuration, ContentType, Error}; +use crate::{apis::ResponseContent, models}; +use reqwest; +use serde::{de::Error as _, Deserialize, Serialize}; + +/// struct for typed errors of method [`auth_jwks_get`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthJwksGetError { + UnknownValue(serde_json::Value), +} + +pub async fn auth_jwks_get( + configuration: &configuration::Configuration, +) -> Result> { + let uri_str = format!("{}/.well-known/jwks.json", configuration.base_path); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `serde_json::Value`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `serde_json::Value`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} diff --git a/engine/sdks/rust/api-full/src/apis/auth_webhook_discovery_api.rs b/engine/sdks/rust/api-full/src/apis/auth_webhook_discovery_api.rs new file mode 100644 index 0000000000..d6301c2b6a --- /dev/null +++ b/engine/sdks/rust/api-full/src/apis/auth_webhook_discovery_api.rs @@ -0,0 +1,122 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use super::{configuration, ContentType, Error}; +use crate::{apis::ResponseContent, models}; +use reqwest; +use serde::{de::Error as _, Deserialize, Serialize}; + +/// struct for typed errors of method [`auth_webhooks_jwks`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthWebhooksJwksError { + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`auth_webhooks_metadata`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthWebhooksMetadataError { + UnknownValue(serde_json::Value), +} + +pub async fn auth_webhooks_jwks( + configuration: &configuration::Configuration, + namespace: &str, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_namespace = namespace; + + let uri_str = format!( + "{}/namespaces/{namespace}/auth/webhooks/jwks.json", + configuration.base_path, + namespace = crate::apis::urlencode(p_namespace) + ); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::AuthWebhookJwks`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::AuthWebhookJwks`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + +pub async fn auth_webhooks_metadata( + configuration: &configuration::Configuration, + namespace: &str, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_namespace = namespace; + + let uri_str = format!( + "{}/namespaces/{namespace}/auth/webhooks", + configuration.base_path, + namespace = crate::apis::urlencode(p_namespace) + ); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::AuthWebhookMetadata`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::AuthWebhookMetadata`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} diff --git a/engine/sdks/rust/api-full/src/apis/auth_webhook_keys_api.rs b/engine/sdks/rust/api-full/src/apis/auth_webhook_keys_api.rs new file mode 100644 index 0000000000..607b723cdf --- /dev/null +++ b/engine/sdks/rust/api-full/src/apis/auth_webhook_keys_api.rs @@ -0,0 +1,246 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use super::{configuration, ContentType, Error}; +use crate::{apis::ResponseContent, models}; +use reqwest; +use serde::{de::Error as _, Deserialize, Serialize}; + +/// struct for typed errors of method [`auth_webhook_keys_get`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthWebhookKeysGetError { + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`auth_webhook_keys_revoke`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthWebhookKeysRevokeError { + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`auth_webhook_keys_rotate`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthWebhookKeysRotateError { + UnknownValue(serde_json::Value), +} + +/// struct for typed errors of method [`auth_webhooks_ensure`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthWebhooksEnsureError { + UnknownValue(serde_json::Value), +} + +pub async fn auth_webhook_keys_get( + configuration: &configuration::Configuration, + namespace: &str, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_namespace = namespace; + + let uri_str = format!( + "{}/namespaces/{namespace}/auth/webhook-keys", + configuration.base_path, + namespace = crate::apis::urlencode(p_namespace) + ); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::AuthWebhookKeyStatus`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::AuthWebhookKeyStatus`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + +pub async fn auth_webhook_keys_revoke( + configuration: &configuration::Configuration, + namespace: &str, + auth_webhook_revoke_request: models::AuthWebhookRevokeRequest, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_namespace = namespace; + let p_auth_webhook_revoke_request = auth_webhook_revoke_request; + + let uri_str = format!( + "{}/namespaces/{namespace}/auth/webhook-keys/revoke", + configuration.base_path, + namespace = crate::apis::urlencode(p_namespace) + ); + let mut req_builder = configuration + .client + .request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + req_builder = req_builder.json(&p_auth_webhook_revoke_request); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::AuthWebhookKeyStatus`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::AuthWebhookKeyStatus`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + +pub async fn auth_webhook_keys_rotate( + configuration: &configuration::Configuration, + namespace: &str, + auth_webhook_rotate_request: models::AuthWebhookRotateRequest, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_namespace = namespace; + let p_auth_webhook_rotate_request = auth_webhook_rotate_request; + + let uri_str = format!( + "{}/namespaces/{namespace}/auth/webhook-keys/rotate", + configuration.base_path, + namespace = crate::apis::urlencode(p_namespace) + ); + let mut req_builder = configuration + .client + .request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + req_builder = req_builder.json(&p_auth_webhook_rotate_request); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::AuthWebhookKeyStatus`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::AuthWebhookKeyStatus`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} + +pub async fn auth_webhooks_ensure( + configuration: &configuration::Configuration, +) -> Result> { + let uri_str = format!("{}/auth/webhooks/ensure", configuration.base_path); + let mut req_builder = configuration + .client + .request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::AuthWebhookMetadata`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::AuthWebhookMetadata`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} diff --git a/engine/sdks/rust/api-full/src/apis/auth_webhook_signing_api.rs b/engine/sdks/rust/api-full/src/apis/auth_webhook_signing_api.rs new file mode 100644 index 0000000000..64271a6aea --- /dev/null +++ b/engine/sdks/rust/api-full/src/apis/auth_webhook_signing_api.rs @@ -0,0 +1,72 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use super::{configuration, ContentType, Error}; +use crate::{apis::ResponseContent, models}; +use reqwest; +use serde::{de::Error as _, Deserialize, Serialize}; + +/// struct for typed errors of method [`auth_webhooks_sign`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AuthWebhooksSignError { + UnknownValue(serde_json::Value), +} + +pub async fn auth_webhooks_sign( + configuration: &configuration::Configuration, + body: Vec, +) -> Result> { + // add a prefix to parameters to efficiently prevent name collisions + let p_body = body; + + let uri_str = format!("{}/auth/webhooks/sign", configuration.base_path); + let mut req_builder = configuration + .client + .request(reqwest::Method::POST, &uri_str); + + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + req_builder = req_builder + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .body(p_body); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::AuthWebhookSignResponse`"))), + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::AuthWebhookSignResponse`")))), + } + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + status, + content, + entity, + })) + } +} diff --git a/engine/sdks/rust/api-full/src/apis/mod.rs b/engine/sdks/rust/api-full/src/apis/mod.rs index 511a3a327f..73ad853c6c 100644 --- a/engine/sdks/rust/api-full/src/apis/mod.rs +++ b/engine/sdks/rust/api-full/src/apis/mod.rs @@ -121,7 +121,13 @@ pub mod actors_list_api; pub mod actors_list_names_api; pub mod actors_reschedule_api; pub mod actors_sleep_api; +pub mod auth_application_tokens_api; +pub mod auth_issuers_api; +pub mod auth_jwks_api; pub mod auth_tokens_api; +pub mod auth_webhook_discovery_api; +pub mod auth_webhook_keys_api; +pub mod auth_webhook_signing_api; pub mod datacenters_api; pub mod envoys_api; pub mod health_api; diff --git a/engine/sdks/rust/api-full/src/models/actor_name.rs b/engine/sdks/rust/api-full/src/models/actor_name.rs index cac8d40d6f..ac2b01b201 100644 --- a/engine/sdks/rust/api-full/src/models/actor_name.rs +++ b/engine/sdks/rust/api-full/src/models/actor_name.rs @@ -14,11 +14,11 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct ActorName { #[serde(rename = "metadata")] - pub metadata: serde_json::Value, + pub metadata: std::collections::HashMap, } impl ActorName { - pub fn new(metadata: serde_json::Value) -> ActorName { + pub fn new(metadata: std::collections::HashMap) -> ActorName { ActorName { metadata } } } diff --git a/engine/sdks/rust/api-full/src/models/auth_application_token_create_request.rs b/engine/sdks/rust/api-full/src/models/auth_application_token_create_request.rs new file mode 100644 index 0000000000..51cbaf93a2 --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_application_token_create_request.rs @@ -0,0 +1,52 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthApplicationTokenCreateRequest { + #[serde(rename = "audience")] + pub audience: String, + #[serde(rename = "claims")] + pub claims: std::collections::HashMap, + #[serde( + rename = "duration", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub duration: Option>, + #[serde(rename = "purpose")] + pub purpose: String, + #[serde( + rename = "subject", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub subject: Option>, +} + +impl AuthApplicationTokenCreateRequest { + pub fn new( + audience: String, + claims: std::collections::HashMap, + purpose: String, + ) -> AuthApplicationTokenCreateRequest { + AuthApplicationTokenCreateRequest { + audience, + claims, + duration: None, + purpose, + subject: None, + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_application_token_create_response.rs b/engine/sdks/rust/api-full/src/models/auth_application_token_create_response.rs new file mode 100644 index 0000000000..573e669b4f --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_application_token_create_response.rs @@ -0,0 +1,36 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthApplicationTokenCreateResponse { + #[serde(rename = "expires_ts")] + pub expires_ts: i64, + #[serde(rename = "issued_ts")] + pub issued_ts: i64, + #[serde(rename = "token")] + pub token: String, +} + +impl AuthApplicationTokenCreateResponse { + pub fn new( + expires_ts: i64, + issued_ts: i64, + token: String, + ) -> AuthApplicationTokenCreateResponse { + AuthApplicationTokenCreateResponse { + expires_ts, + issued_ts, + token, + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_issuer.rs b/engine/sdks/rust/api-full/src/models/auth_issuer.rs new file mode 100644 index 0000000000..622f0a6a7f --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_issuer.rs @@ -0,0 +1,48 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthIssuer { + #[serde(rename = "algorithm")] + pub algorithm: String, + #[serde(rename = "audience")] + pub audience: String, + #[serde(rename = "grant_ceiling")] + pub grant_ceiling: Vec, + #[serde(rename = "issuer")] + pub issuer: String, + #[serde(rename = "jwks_url")] + pub jwks_url: String, + #[serde(rename = "name")] + pub name: String, +} + +impl AuthIssuer { + pub fn new( + algorithm: String, + audience: String, + grant_ceiling: Vec, + issuer: String, + jwks_url: String, + name: String, + ) -> AuthIssuer { + AuthIssuer { + algorithm, + audience, + grant_ceiling, + issuer, + jwks_url, + name, + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_issuer_get_response.rs b/engine/sdks/rust/api-full/src/models/auth_issuer_get_response.rs new file mode 100644 index 0000000000..39a55b2c39 --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_issuer_get_response.rs @@ -0,0 +1,26 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthIssuerGetResponse { + #[serde(rename = "issuer")] + pub issuer: Box, +} + +impl AuthIssuerGetResponse { + pub fn new(issuer: models::AuthIssuer) -> AuthIssuerGetResponse { + AuthIssuerGetResponse { + issuer: Box::new(issuer), + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_issuer_grant.rs b/engine/sdks/rust/api-full/src/models/auth_issuer_grant.rs new file mode 100644 index 0000000000..ca99e3c62a --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_issuer_grant.rs @@ -0,0 +1,36 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthIssuerGrant { + #[serde(rename = "operations")] + pub operations: Vec, + #[serde(rename = "resource")] + pub resource: models::AclResourceKind, + #[serde(rename = "target")] + pub target: Box, +} + +impl AuthIssuerGrant { + pub fn new( + operations: Vec, + resource: models::AclResourceKind, + target: models::AclTargetScope, + ) -> AuthIssuerGrant { + AuthIssuerGrant { + operations, + resource, + target: Box::new(target), + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_issuer_list_response.rs b/engine/sdks/rust/api-full/src/models/auth_issuer_list_response.rs new file mode 100644 index 0000000000..d80101bd93 --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_issuer_list_response.rs @@ -0,0 +1,24 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthIssuerListResponse { + #[serde(rename = "issuers")] + pub issuers: Vec, +} + +impl AuthIssuerListResponse { + pub fn new(issuers: Vec) -> AuthIssuerListResponse { + AuthIssuerListResponse { issuers } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_issuer_upsert_request.rs b/engine/sdks/rust/api-full/src/models/auth_issuer_upsert_request.rs new file mode 100644 index 0000000000..f7b59ba9f8 --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_issuer_upsert_request.rs @@ -0,0 +1,48 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthIssuerUpsertRequest { + /// Pinned signature algorithm: EdDSA, RS256, or ES256. + #[serde(rename = "algorithm")] + pub algorithm: String, + /// Required, exact `aud` claim for Rivet (never optional). + #[serde(rename = "audience")] + pub audience: String, + /// Maximum access this issuer may embed in a signed Rivet JWT. + #[serde(rename = "grant_ceiling")] + pub grant_ceiling: Vec, + /// Exact `iss` claim to trust for this namespace. + #[serde(rename = "issuer")] + pub issuer: String, + #[serde(rename = "jwks_url")] + pub jwks_url: String, +} + +impl AuthIssuerUpsertRequest { + pub fn new( + algorithm: String, + audience: String, + grant_ceiling: Vec, + issuer: String, + jwks_url: String, + ) -> AuthIssuerUpsertRequest { + AuthIssuerUpsertRequest { + algorithm, + audience, + grant_ceiling, + issuer, + jwks_url, + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_issuer_upsert_response.rs b/engine/sdks/rust/api-full/src/models/auth_issuer_upsert_response.rs new file mode 100644 index 0000000000..6097c53e2d --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_issuer_upsert_response.rs @@ -0,0 +1,26 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthIssuerUpsertResponse { + #[serde(rename = "issuer")] + pub issuer: Box, +} + +impl AuthIssuerUpsertResponse { + pub fn new(issuer: models::AuthIssuer) -> AuthIssuerUpsertResponse { + AuthIssuerUpsertResponse { + issuer: Box::new(issuer), + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_webhook_jwks.rs b/engine/sdks/rust/api-full/src/models/auth_webhook_jwks.rs new file mode 100644 index 0000000000..e5359281be --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_webhook_jwks.rs @@ -0,0 +1,24 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthWebhookJwks { + #[serde(rename = "keys")] + pub keys: Vec, +} + +impl AuthWebhookJwks { + pub fn new(keys: Vec) -> AuthWebhookJwks { + AuthWebhookJwks { keys } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_webhook_key_status.rs b/engine/sdks/rust/api-full/src/models/auth_webhook_key_status.rs new file mode 100644 index 0000000000..f389fb4e11 --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_webhook_key_status.rs @@ -0,0 +1,53 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthWebhookKeyStatus { + #[serde(rename = "active_kid")] + pub active_kid: String, + #[serde(rename = "generation")] + pub generation: i64, + #[serde( + rename = "last_emergency_generation", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub last_emergency_generation: Option>, + #[serde( + rename = "last_emergency_request_id", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub last_emergency_request_id: Option>, + #[serde( + rename = "pending_kid", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub pending_kid: Option>, +} + +impl AuthWebhookKeyStatus { + pub fn new(active_kid: String, generation: i64) -> AuthWebhookKeyStatus { + AuthWebhookKeyStatus { + active_kid, + generation, + last_emergency_generation: None, + last_emergency_request_id: None, + pending_kid: None, + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_webhook_metadata.rs b/engine/sdks/rust/api-full/src/models/auth_webhook_metadata.rs new file mode 100644 index 0000000000..490ce5e13a --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_webhook_metadata.rs @@ -0,0 +1,44 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthWebhookMetadata { + #[serde(rename = "alg")] + pub alg: String, + #[serde(rename = "generation")] + pub generation: i64, + #[serde(rename = "jwks_url")] + pub jwks_url: String, + #[serde(rename = "kid")] + pub kid: String, + #[serde(rename = "namespace_id")] + pub namespace_id: String, +} + +impl AuthWebhookMetadata { + pub fn new( + alg: String, + generation: i64, + jwks_url: String, + kid: String, + namespace_id: String, + ) -> AuthWebhookMetadata { + AuthWebhookMetadata { + alg, + generation, + jwks_url, + kid, + namespace_id, + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_webhook_public_key.rs b/engine/sdks/rust/api-full/src/models/auth_webhook_public_key.rs new file mode 100644 index 0000000000..9e989e6756 --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_webhook_public_key.rs @@ -0,0 +1,48 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthWebhookPublicKey { + #[serde(rename = "alg")] + pub alg: String, + #[serde(rename = "crv")] + pub crv: String, + #[serde(rename = "kid")] + pub kid: String, + #[serde(rename = "kty")] + pub kty: String, + #[serde(rename = "use")] + pub r#use: String, + #[serde(rename = "x")] + pub x: String, +} + +impl AuthWebhookPublicKey { + pub fn new( + alg: String, + crv: String, + kid: String, + kty: String, + r#use: String, + x: String, + ) -> AuthWebhookPublicKey { + AuthWebhookPublicKey { + alg, + crv, + kid, + kty, + r#use, + x, + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_webhook_revoke_request.rs b/engine/sdks/rust/api-full/src/models/auth_webhook_revoke_request.rs new file mode 100644 index 0000000000..9e5f8ad983 --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_webhook_revoke_request.rs @@ -0,0 +1,36 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthWebhookRevokeRequest { + #[serde(rename = "expected_generation")] + pub expected_generation: i64, + #[serde(rename = "kids")] + pub kids: Vec, + #[serde(rename = "request_id")] + pub request_id: String, +} + +impl AuthWebhookRevokeRequest { + pub fn new( + expected_generation: i64, + kids: Vec, + request_id: String, + ) -> AuthWebhookRevokeRequest { + AuthWebhookRevokeRequest { + expected_generation, + kids, + request_id, + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_webhook_rotate_request.rs b/engine/sdks/rust/api-full/src/models/auth_webhook_rotate_request.rs new file mode 100644 index 0000000000..aa6a4cf409 --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_webhook_rotate_request.rs @@ -0,0 +1,26 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthWebhookRotateRequest { + #[serde(rename = "expected_generation")] + pub expected_generation: i64, +} + +impl AuthWebhookRotateRequest { + pub fn new(expected_generation: i64) -> AuthWebhookRotateRequest { + AuthWebhookRotateRequest { + expected_generation, + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/auth_webhook_sign_response.rs b/engine/sdks/rust/api-full/src/models/auth_webhook_sign_response.rs new file mode 100644 index 0000000000..e6a1324bb3 --- /dev/null +++ b/engine/sdks/rust/api-full/src/models/auth_webhook_sign_response.rs @@ -0,0 +1,34 @@ +/* + * rivet-api-public + * + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.3.20 + * Contact: developer@rivet.dev + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthWebhookSignResponse { + #[serde(rename = "kid")] + pub kid: String, + /// Unpadded base64url Ed25519 signature of timestamp + '.' + raw request body. + #[serde(rename = "signature")] + pub signature: String, + /// Canonical decimal Unix seconds, generated by the control plane. + #[serde(rename = "timestamp")] + pub timestamp: String, +} + +impl AuthWebhookSignResponse { + pub fn new(kid: String, signature: String, timestamp: String) -> AuthWebhookSignResponse { + AuthWebhookSignResponse { + kid, + signature, + timestamp, + } + } +} diff --git a/engine/sdks/rust/api-full/src/models/envoy.rs b/engine/sdks/rust/api-full/src/models/envoy.rs index 2a4fdddb4a..57e08439a0 100644 --- a/engine/sdks/rust/api-full/src/models/envoy.rs +++ b/engine/sdks/rust/api-full/src/models/envoy.rs @@ -19,22 +19,37 @@ pub struct Envoy { pub datacenter: String, #[serde(rename = "envoy_key")] pub envoy_key: String, - #[serde(rename = "last_connected_ts", skip_serializing_if = "Option::is_none")] - pub last_connected_ts: Option, + #[serde( + rename = "last_connected_ts", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub last_connected_ts: Option>, #[serde(rename = "last_ping_ts")] pub last_ping_ts: i64, #[serde(rename = "last_rtt")] pub last_rtt: i32, - #[serde(rename = "metadata", skip_serializing_if = "Option::is_none")] - pub metadata: Option, + #[serde( + rename = "metadata", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub metadata: Option>>, #[serde(rename = "namespace_id")] pub namespace_id: String, #[serde(rename = "pool_name")] pub pool_name: String, #[serde(rename = "slots")] pub slots: i64, - #[serde(rename = "stop_ts", skip_serializing_if = "Option::is_none")] - pub stop_ts: Option, + #[serde( + rename = "stop_ts", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub stop_ts: Option>, #[serde(rename = "version")] pub version: i32, } diff --git a/engine/sdks/rust/api-full/src/models/runner.rs b/engine/sdks/rust/api-full/src/models/runner.rs index a29fa4f1f4..9b28e416b2 100644 --- a/engine/sdks/rust/api-full/src/models/runner.rs +++ b/engine/sdks/rust/api-full/src/models/runner.rs @@ -17,18 +17,33 @@ pub struct Runner { pub create_ts: i64, #[serde(rename = "datacenter")] pub datacenter: String, - #[serde(rename = "drain_ts", skip_serializing_if = "Option::is_none")] - pub drain_ts: Option, + #[serde( + rename = "drain_ts", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub drain_ts: Option>, #[serde(rename = "key")] pub key: String, - #[serde(rename = "last_connected_ts", skip_serializing_if = "Option::is_none")] - pub last_connected_ts: Option, + #[serde( + rename = "last_connected_ts", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub last_connected_ts: Option>, #[serde(rename = "last_ping_ts")] pub last_ping_ts: i64, #[serde(rename = "last_rtt")] pub last_rtt: i32, - #[serde(rename = "metadata", skip_serializing_if = "Option::is_none")] - pub metadata: Option, + #[serde( + rename = "metadata", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub metadata: Option>>, #[serde(rename = "name")] pub name: String, #[serde(rename = "namespace_id")] @@ -37,8 +52,13 @@ pub struct Runner { pub remaining_slots: i32, #[serde(rename = "runner_id")] pub runner_id: String, - #[serde(rename = "stop_ts", skip_serializing_if = "Option::is_none")] - pub stop_ts: Option, + #[serde( + rename = "stop_ts", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub stop_ts: Option>, #[serde(rename = "total_slots")] pub total_slots: i32, #[serde(rename = "version")] diff --git a/engine/sdks/rust/api-full/src/models/runner_config_kind_one_of_1_serverless.rs b/engine/sdks/rust/api-full/src/models/runner_config_kind_one_of_1_serverless.rs index 4e91e06d06..b804cb2206 100644 --- a/engine/sdks/rust/api-full/src/models/runner_config_kind_one_of_1_serverless.rs +++ b/engine/sdks/rust/api-full/src/models/runner_config_kind_one_of_1_serverless.rs @@ -52,8 +52,13 @@ pub struct RunnerConfigKindOneOf1Serverless { skip_serializing_if = "Option::is_none" )] pub drain_on_version_upgrade: Option>, - #[serde(rename = "headers", skip_serializing_if = "Option::is_none")] - pub headers: Option>, + #[serde( + rename = "headers", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub headers: Option>>, #[serde( rename = "max_concurrent_actors", default, diff --git a/engine/sdks/rust/api-full/src/models/runner_config_response.rs b/engine/sdks/rust/api-full/src/models/runner_config_response.rs index 6eed85150d..8b0bea4a73 100644 --- a/engine/sdks/rust/api-full/src/models/runner_config_response.rs +++ b/engine/sdks/rust/api-full/src/models/runner_config_response.rs @@ -20,9 +20,11 @@ pub struct RunnerConfigResponse { /// Deprecated. #[serde( rename = "drain_on_version_upgrade", + default, + with = "::serde_with::rust::double_option", skip_serializing_if = "Option::is_none" )] - pub drain_on_version_upgrade: Option, + pub drain_on_version_upgrade: Option>, #[serde( rename = "metadata", default, diff --git a/engine/sdks/rust/data/build.rs b/engine/sdks/rust/data/build.rs index 8b003fba08..9652c86081 100644 --- a/engine/sdks/rust/data/build.rs +++ b/engine/sdks/rust/data/build.rs @@ -14,6 +14,8 @@ fn main() -> Result<(), Box> { .ok_or("Failed to find workspace root")?; let schema_dir = workspace_root.join("sdks").join("schemas").join("data"); + // Individual schema watches do not detect a newly added versioned schema. + println!("cargo:rerun-if-changed={}", schema_dir.display()); let cfg = vbare_compiler::Config::default(); vbare_compiler::process_schemas_with_config(&schema_dir, &cfg)?; diff --git a/engine/sdks/rust/data/src/versioned/auth_jwt_external_issuers.rs b/engine/sdks/rust/data/src/versioned/auth_jwt_external_issuers.rs new file mode 100644 index 0000000000..7e55cb3f01 --- /dev/null +++ b/engine/sdks/rust/data/src/versioned/auth_jwt_external_issuers.rs @@ -0,0 +1,35 @@ +use anyhow::{Result, bail}; +use vbare::OwnedVersionedData; + +use crate::generated::auth_jwt_external_issuers_v1; + +pub enum AuthJwtExternalIssuersData { + V1(auth_jwt_external_issuers_v1::Data), +} + +impl OwnedVersionedData for AuthJwtExternalIssuersData { + type Latest = auth_jwt_external_issuers_v1::Data; + + fn wrap_latest(latest: Self::Latest) -> Self { + Self::V1(latest) + } + + fn unwrap_latest(self) -> Result { + match self { + Self::V1(value) => Ok(value), + } + } + + fn deserialize_version(payload: &[u8], version: u16) -> Result { + match version { + 1 => Ok(Self::V1(serde_bare::from_slice(payload)?)), + _ => bail!("unsupported JWT external issuer registry version: {version}"), + } + } + + fn serialize_version(self, _version: u16) -> Result> { + match self { + Self::V1(value) => Ok(serde_bare::to_vec(&value)?), + } + } +} diff --git a/engine/sdks/rust/data/src/versioned/auth_signing_quota.rs b/engine/sdks/rust/data/src/versioned/auth_signing_quota.rs new file mode 100644 index 0000000000..9f9f4758e0 --- /dev/null +++ b/engine/sdks/rust/data/src/versioned/auth_signing_quota.rs @@ -0,0 +1,35 @@ +use anyhow::{Result, bail}; +use vbare::OwnedVersionedData; + +use crate::generated::auth_signing_quota_v1; + +pub enum AuthSigningQuotaData { + V1(auth_signing_quota_v1::Data), +} + +impl OwnedVersionedData for AuthSigningQuotaData { + type Latest = auth_signing_quota_v1::Data; + + fn wrap_latest(latest: Self::Latest) -> Self { + Self::V1(latest) + } + + fn unwrap_latest(self) -> Result { + match self { + Self::V1(value) => Ok(value), + } + } + + fn deserialize_version(payload: &[u8], version: u16) -> Result { + match version { + 1 => Ok(Self::V1(serde_bare::from_slice(payload)?)), + _ => bail!("unsupported signing quota version: {version}"), + } + } + + fn serialize_version(self, _version: u16) -> Result> { + match self { + Self::V1(value) => Ok(serde_bare::to_vec(&value)?), + } + } +} diff --git a/engine/sdks/rust/data/src/versioned/auth_webhook_key_ring.rs b/engine/sdks/rust/data/src/versioned/auth_webhook_key_ring.rs new file mode 100644 index 0000000000..7ef27916bf --- /dev/null +++ b/engine/sdks/rust/data/src/versioned/auth_webhook_key_ring.rs @@ -0,0 +1,35 @@ +use anyhow::{Result, bail}; +use vbare::OwnedVersionedData; + +use crate::generated::auth_webhook_key_ring_v1; + +pub enum AuthWebhookKeyRingData { + V1(auth_webhook_key_ring_v1::Data), +} + +impl OwnedVersionedData for AuthWebhookKeyRingData { + type Latest = auth_webhook_key_ring_v1::Data; + + fn wrap_latest(latest: Self::Latest) -> Self { + Self::V1(latest) + } + + fn unwrap_latest(self) -> Result { + match self { + Self::V1(value) => Ok(value), + } + } + + fn deserialize_version(payload: &[u8], version: u16) -> Result { + match version { + 1 => Ok(Self::V1(serde_bare::from_slice(payload)?)), + _ => bail!("unsupported webhook key ring version: {version}"), + } + } + + fn serialize_version(self, _version: u16) -> Result> { + match self { + Self::V1(value) => Ok(serde_bare::to_vec(&value)?), + } + } +} diff --git a/engine/sdks/rust/data/src/versioned/mod.rs b/engine/sdks/rust/data/src/versioned/mod.rs index c1f1c7b8d5..c3bb72c42f 100644 --- a/engine/sdks/rust/data/src/versioned/mod.rs +++ b/engine/sdks/rust/data/src/versioned/mod.rs @@ -1,4 +1,6 @@ +mod auth_signing_quota; use anyhow::{Ok, Result, bail}; +pub use auth_signing_quota::*; use gas::prelude::Id; use vbare::OwnedVersionedData; @@ -6,11 +8,15 @@ use crate::converted; use crate::generated::*; mod auth_grants; +mod auth_jwt_external_issuers; mod auth_jwt_key_ring; +mod auth_webhook_key_ring; mod namespace_runner_config; pub use auth_grants::*; +pub use auth_jwt_external_issuers::*; pub use auth_jwt_key_ring::*; +pub use auth_webhook_key_ring::*; pub use namespace_runner_config::*; #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/engine/sdks/schemas/data/auth.jwt_external_issuers.v1.bare b/engine/sdks/schemas/data/auth.jwt_external_issuers.v1.bare new file mode 100644 index 0000000000..88da8ad647 --- /dev/null +++ b/engine/sdks/schemas/data/auth.jwt_external_issuers.v1.bare @@ -0,0 +1,14 @@ +type Issuer struct { + name: str + issuer: str + audience: str + jwksUrl: str + algorithm: str + grantCeiling: str +} + +type Data struct { + namespaceId: data + generation: u64 + issuers: list +} diff --git a/engine/sdks/schemas/data/auth.signing_quota.v1.bare b/engine/sdks/schemas/data/auth.signing_quota.v1.bare new file mode 100644 index 0000000000..d5fafea953 --- /dev/null +++ b/engine/sdks/schemas/data/auth.signing_quota.v1.bare @@ -0,0 +1,8 @@ +type Data struct { + windowStart: i64 + applicationOperations: u64 + webhookOperations: u64 + webhookBytes: u64 + leaderDatacenter: u16 + leaderGeneration: u64 +} diff --git a/engine/sdks/schemas/data/auth.webhook_key_ring.v1.bare b/engine/sdks/schemas/data/auth.webhook_key_ring.v1.bare new file mode 100644 index 0000000000..1d35b87bd4 --- /dev/null +++ b/engine/sdks/schemas/data/auth.webhook_key_ring.v1.bare @@ -0,0 +1,13 @@ +type EmergencyReceipt struct { + requestId: data + expectedGeneration: u64 + committedGeneration: u64 + requestedKids: list +} + +type Data struct { + namespaceId: data + # A complete versioned AuthJwtKeyRingData value; the shared data library owns that schema. + ring: data + lastEmergency: optional +} diff --git a/engine/sdks/typescript/api-full/package.json b/engine/sdks/typescript/api-full/package.json index 732b3bdcaf..3b7d67a9d1 100644 --- a/engine/sdks/typescript/api-full/package.json +++ b/engine/sdks/typescript/api-full/package.json @@ -1,68 +1,69 @@ { - "name": "@rivetkit/engine-api-full", - "version": "2.3.20", - "repository": "https://github.com/rivet-dev/rivet/tree/main/engine/sdks/typescript", - "files": [ - "dist", - "types", - "core.d.ts", - "serialization.d.ts" - ], - "exports": { - ".": { - "node": { - "import": "./dist/node/esm/index.js", - "require": "./dist/node/cjs/index.js" - }, - "import": "./dist/browser/esm/index.js", - "require": "./dist/browser/cjs/index.js", - "default": "./dist/browser/cjs/index.js", - "types": "./types/index.d.ts" - }, - "./core": { - "node": { - "import": "./dist/node/esm/core.js", - "require": "./dist/node/cjs/core.js" - }, - "import": "./dist/browser/esm/core.js", - "require": "./dist/browser/cjs/core.js", - "default": "./dist/browser/cjs/core.js", - "types": "./types/core/index.d.ts" - }, - "./serialization": { - "node": { - "import": "./dist/node/esm/serialization.js", - "require": "./dist/node/cjs/serialization.js" - }, - "import": "./dist/browser/esm/serialization.js", - "require": "./dist/browser/cjs/serialization.js", - "default": "./dist/browser/cjs/serialization.js", - "types": "./types/serialization/index.d.ts" - } - }, - "types": "./types/index.d.ts", - "scripts": { - "compile": "tsc", - "bundle": "node build.js", - "build": "pnpm compile && pnpm bundle", - "prepack": "pnpm run build" - }, - "dependencies": { - "form-data": "^4.0.4", - "js-base64": "^3.7.8", - "node-fetch": "^2.7.0", - "qs": "^6.14.0", - "readable-stream": "^4.7.0", - "url-join": "^5.0.0" - }, - "devDependencies": { - "@types/node": "17.0.33", - "@types/node-fetch": "2.6.11", - "@types/qs": "6.9.8", - "@types/readable-stream": "^4.0.21", - "@types/url-join": "4.0.1", - "esbuild": "^0.19.12", - "prettier": "2.7.1", - "typescript": "5.8.2" - } + "name": "@rivetkit/engine-api-full", + "version": "2.3.20", + "repository": "https://github.com/rivet-dev/rivet/tree/main/engine/sdks/typescript", + "files": [ + "dist", + "types", + "core.d.ts", + "serialization.d.ts" + ], + "exports": { + ".": { + "node": { + "import": "./dist/node/esm/index.js", + "require": "./dist/node/cjs/index.js" + }, + "import": "./dist/browser/esm/index.js", + "require": "./dist/browser/cjs/index.js", + "default": "./dist/browser/cjs/index.js", + "types": "./types/index.d.ts" + }, + "./core": { + "node": { + "import": "./dist/node/esm/core.js", + "require": "./dist/node/cjs/core.js" + }, + "import": "./dist/browser/esm/core.js", + "require": "./dist/browser/cjs/core.js", + "default": "./dist/browser/cjs/core.js", + "types": "./types/core/index.d.ts" + }, + "./serialization": { + "node": { + "import": "./dist/node/esm/serialization.js", + "require": "./dist/node/cjs/serialization.js" + }, + "import": "./dist/browser/esm/serialization.js", + "require": "./dist/browser/cjs/serialization.js", + "default": "./dist/browser/cjs/serialization.js", + "types": "./types/serialization/index.d.ts" + } + }, + "types": "./types/index.d.ts", + "scripts": { + "compile": "tsc", + "bundle": "node build.js", + "build": "pnpm compile && pnpm bundle", + "prepack": "pnpm run build" + }, + "dependencies": { + "form-data": "^4.0.4", + "js-base64": "^3.7.8", + "node-fetch": "^2.7.0", + "qs": "^6.14.0", + "readable-stream": "^4.7.0", + "url-join": "^5.0.0" + }, + "devDependencies": { + "@types/node": "17.0.33", + "@types/node-fetch": "2.6.11", + "@types/qs": "6.9.8", + "@types/readable-stream": "^4.0.21", + "@types/url-join": "4.0.1", + "esbuild": "^0.19.12", + "prettier": "2.7.1", + "typescript": "5.8.2" + }, + "license": "SEE LICENSE IN LICENSE" } diff --git a/engine/sdks/typescript/api-full/src/Client.ts b/engine/sdks/typescript/api-full/src/Client.ts index b03a509bb1..b737528831 100644 --- a/engine/sdks/typescript/api-full/src/Client.ts +++ b/engine/sdks/typescript/api-full/src/Client.ts @@ -20,7 +20,7 @@ export declare namespace RivetClient { environment: core.Supplier; /** Specify a custom URL to connect the client to. */ baseUrl?: core.Supplier; - token: core.Supplier; + token?: core.Supplier; fetcher?: core.FetchFunction; } @@ -71,6 +71,14 @@ export class RivetClient { return (this._namespaces ??= new Namespaces(this._options)); } + public get authIssuers(): AuthIssuers { + return (this._authIssuers ??= new AuthIssuers(this._options)); + } + + public get authWebhookDiscovery(): AuthWebhookDiscovery { + return (this._authWebhookDiscovery ??= new AuthWebhookDiscovery(this._options)); + } + public get runners(): Runners { return (this._runners ??= new Runners(this._options)); } @@ -1171,7 +1179,12 @@ export class RivetClient { } } - protected async _getAuthorizationHeader(): Promise { - return `Bearer ${await core.Supplier.get(this._options.token)}`; + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; } } diff --git a/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/Client.ts new file mode 100644 index 0000000000..cf03098085 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/Client.ts @@ -0,0 +1,115 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as core from "../../../../core"; +import * as Rivet from "../../../index"; +import * as serializers from "../../../../serialization/index"; +import urlJoin from "url-join"; +import * as errors from "../../../../errors/index"; + +export declare namespace AuthApplicationTokens { + export interface Options { + environment: core.Supplier; + /** Specify a custom URL to connect the client to. */ + baseUrl?: core.Supplier; + token?: core.Supplier; + fetcher?: core.FetchFunction; + } + + export interface RequestOptions { + /** The maximum time to wait for a response in seconds. */ + timeoutInSeconds?: number; + /** The number of times to retry the request. Defaults to 2. */ + maxRetries?: number; + /** A hook to abort the request. */ + abortSignal?: AbortSignal; + /** Additional headers to include in the request. */ + headers?: Record; + } +} + +export class AuthApplicationTokens { + constructor(protected readonly _options: AuthApplicationTokens.Options) {} + + /** + * @param {Rivet.AuthApplicationTokenCreateRequest} request + * @param {AuthApplicationTokens.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authApplicationTokens.create({ + * audience: "audience", + * claims: { + * "key": "value" + * }, + * purpose: "purpose" + * }) + */ + public async create( + request: Rivet.AuthApplicationTokenCreateRequest, + requestOptions?: AuthApplicationTokens.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + "auth/application-tokens", + ), + method: "POST", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + body: serializers.AuthApplicationTokenCreateRequest.jsonOrThrow(request, { + unrecognizedObjectKeys: "strip", + }), + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthApplicationTokenCreateResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError("Timeout exceeded when calling POST /auth/application-tokens."); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; + } +} diff --git a/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/index.ts new file mode 100644 index 0000000000..415726b7fe --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/index.ts @@ -0,0 +1 @@ +export * from "./requests"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/requests/AuthApplicationTokenCreateRequest.ts b/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/requests/AuthApplicationTokenCreateRequest.ts new file mode 100644 index 0000000000..686a8c5ad6 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/requests/AuthApplicationTokenCreateRequest.ts @@ -0,0 +1,21 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +/** + * @example + * { + * audience: "audience", + * claims: { + * "key": "value" + * }, + * purpose: "purpose" + * } + */ +export interface AuthApplicationTokenCreateRequest { + audience: string; + claims: Record; + duration?: number; + purpose: string; + subject?: string; +} diff --git a/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/requests/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/requests/index.ts new file mode 100644 index 0000000000..424275f719 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/client/requests/index.ts @@ -0,0 +1 @@ +export { type AuthApplicationTokenCreateRequest } from "./AuthApplicationTokenCreateRequest"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/index.ts new file mode 100644 index 0000000000..5ec76921e1 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authApplicationTokens/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/Client.ts new file mode 100644 index 0000000000..d834eba334 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/Client.ts @@ -0,0 +1,322 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as core from "../../../../core"; +import * as Rivet from "../../../index"; +import urlJoin from "url-join"; +import * as serializers from "../../../../serialization/index"; +import * as errors from "../../../../errors/index"; + +export declare namespace AuthIssuers { + export interface Options { + environment: core.Supplier; + /** Specify a custom URL to connect the client to. */ + baseUrl?: core.Supplier; + token?: core.Supplier; + fetcher?: core.FetchFunction; + } + + export interface RequestOptions { + /** The maximum time to wait for a response in seconds. */ + timeoutInSeconds?: number; + /** The number of times to retry the request. Defaults to 2. */ + maxRetries?: number; + /** A hook to abort the request. */ + abortSignal?: AbortSignal; + /** Additional headers to include in the request. */ + headers?: Record; + } +} + +export class AuthIssuers { + constructor(protected readonly _options: AuthIssuers.Options) {} + + /** + * @param {string} namespace - Namespace name + * @param {AuthIssuers.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authIssuers.list("namespace") + */ + public async list( + namespace: string, + requestOptions?: AuthIssuers.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `namespaces/${encodeURIComponent(namespace)}/auth/issuers`, + ), + method: "GET", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthIssuerListResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError( + "Timeout exceeded when calling GET /namespaces/{namespace}/auth/issuers.", + ); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + /** + * @param {string} namespace - Namespace name + * @param {string} issuerName - Issuer registration name + * @param {AuthIssuers.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authIssuers.get("namespace", "issuer_name") + */ + public async get( + namespace: string, + issuerName: string, + requestOptions?: AuthIssuers.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `namespaces/${encodeURIComponent(namespace)}/auth/issuers/${encodeURIComponent(issuerName)}`, + ), + method: "GET", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthIssuerGetResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError( + "Timeout exceeded when calling GET /namespaces/{namespace}/auth/issuers/{issuer_name}.", + ); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + /** + * @param {string} namespace - Namespace name + * @param {string} issuerName - Issuer registration name + * @param {Rivet.AuthIssuerUpsertRequest} request + * @param {AuthIssuers.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authIssuers.upsert("namespace", "issuer_name", { + * algorithm: "algorithm", + * audience: "audience", + * grantCeiling: [{ + * operations: ["read"], + * resource: "namespace", + * target: "any" + * }], + * issuer: "issuer", + * jwksUrl: "jwks_url" + * }) + */ + public async upsert( + namespace: string, + issuerName: string, + request: Rivet.AuthIssuerUpsertRequest, + requestOptions?: AuthIssuers.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `namespaces/${encodeURIComponent(namespace)}/auth/issuers/${encodeURIComponent(issuerName)}`, + ), + method: "PUT", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + body: serializers.AuthIssuerUpsertRequest.jsonOrThrow(request, { unrecognizedObjectKeys: "strip" }), + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthIssuerUpsertResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError( + "Timeout exceeded when calling PUT /namespaces/{namespace}/auth/issuers/{issuer_name}.", + ); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + /** + * @param {string} namespace - Namespace name + * @param {string} issuerName - Issuer registration name + * @param {AuthIssuers.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authIssuers.delete("namespace", "issuer_name") + */ + public async delete( + namespace: string, + issuerName: string, + requestOptions?: AuthIssuers.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `namespaces/${encodeURIComponent(namespace)}/auth/issuers/${encodeURIComponent(issuerName)}`, + ), + method: "DELETE", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthIssuerDeleteResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError( + "Timeout exceeded when calling DELETE /namespaces/{namespace}/auth/issuers/{issuer_name}.", + ); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; + } +} diff --git a/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/index.ts new file mode 100644 index 0000000000..415726b7fe --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/index.ts @@ -0,0 +1 @@ +export * from "./requests"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/requests/AuthIssuerUpsertRequest.ts b/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/requests/AuthIssuerUpsertRequest.ts new file mode 100644 index 0000000000..0bedbea15d --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/requests/AuthIssuerUpsertRequest.ts @@ -0,0 +1,31 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as Rivet from "../../../../index"; + +/** + * @example + * { + * algorithm: "algorithm", + * audience: "audience", + * grantCeiling: [{ + * operations: ["read"], + * resource: "namespace", + * target: "any" + * }], + * issuer: "issuer", + * jwksUrl: "jwks_url" + * } + */ +export interface AuthIssuerUpsertRequest { + /** Pinned signature algorithm: EdDSA, RS256, or ES256. */ + algorithm: string; + /** Required, exact `aud` claim for Rivet (never optional). */ + audience: string; + /** Maximum access this issuer may embed in a signed Rivet JWT. */ + grantCeiling: Rivet.AuthIssuerGrant[]; + /** Exact `iss` claim to trust for this namespace. */ + issuer: string; + jwksUrl: string; +} diff --git a/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/requests/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/requests/index.ts new file mode 100644 index 0000000000..5951702908 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authIssuers/client/requests/index.ts @@ -0,0 +1 @@ +export { type AuthIssuerUpsertRequest } from "./AuthIssuerUpsertRequest"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authIssuers/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authIssuers/index.ts new file mode 100644 index 0000000000..5ec76921e1 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authIssuers/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authJwks/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/authJwks/client/Client.ts new file mode 100644 index 0000000000..6cb732d4f6 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authJwks/client/Client.ts @@ -0,0 +1,94 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as core from "../../../../core"; +import urlJoin from "url-join"; +import * as errors from "../../../../errors/index"; + +export declare namespace AuthJwks { + export interface Options { + environment: core.Supplier; + /** Specify a custom URL to connect the client to. */ + baseUrl?: core.Supplier; + token?: core.Supplier; + fetcher?: core.FetchFunction; + } + + export interface RequestOptions { + /** The maximum time to wait for a response in seconds. */ + timeoutInSeconds?: number; + /** The number of times to retry the request. Defaults to 2. */ + maxRetries?: number; + /** A hook to abort the request. */ + abortSignal?: AbortSignal; + /** Additional headers to include in the request. */ + headers?: Record; + } +} + +export class AuthJwks { + constructor(protected readonly _options: AuthJwks.Options) {} + + /** + * @param {AuthJwks.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authJwks.get() + */ + public async get(requestOptions?: AuthJwks.RequestOptions): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + ".well-known/jwks.json", + ), + method: "GET", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return _response.body; + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError("Timeout exceeded when calling GET /.well-known/jwks.json."); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; + } +} diff --git a/engine/sdks/typescript/api-full/src/api/resources/authJwks/client/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authJwks/client/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authJwks/client/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authJwks/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authJwks/index.ts new file mode 100644 index 0000000000..5ec76921e1 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authJwks/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authTokens/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/authTokens/client/Client.ts index 9b56d5dab6..65501cd1f3 100644 --- a/engine/sdks/typescript/api-full/src/api/resources/authTokens/client/Client.ts +++ b/engine/sdks/typescript/api-full/src/api/resources/authTokens/client/Client.ts @@ -13,7 +13,7 @@ export declare namespace AuthTokens { environment: core.Supplier; /** Specify a custom URL to connect the client to. */ baseUrl?: core.Supplier; - token: core.Supplier; + token?: core.Supplier; fetcher?: core.FetchFunction; } @@ -162,7 +162,12 @@ export class AuthTokens { } } - protected async _getAuthorizationHeader(): Promise { - return `Bearer ${await core.Supplier.get(this._options.token)}`; + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; } } diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/client/Client.ts new file mode 100644 index 0000000000..16adbb29a1 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/client/Client.ts @@ -0,0 +1,173 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as core from "../../../../core"; +import * as Rivet from "../../../index"; +import urlJoin from "url-join"; +import * as serializers from "../../../../serialization/index"; +import * as errors from "../../../../errors/index"; + +export declare namespace AuthWebhookDiscovery { + export interface Options { + environment: core.Supplier; + /** Specify a custom URL to connect the client to. */ + baseUrl?: core.Supplier; + token?: core.Supplier; + fetcher?: core.FetchFunction; + } + + export interface RequestOptions { + /** The maximum time to wait for a response in seconds. */ + timeoutInSeconds?: number; + /** The number of times to retry the request. Defaults to 2. */ + maxRetries?: number; + /** A hook to abort the request. */ + abortSignal?: AbortSignal; + /** Additional headers to include in the request. */ + headers?: Record; + } +} + +export class AuthWebhookDiscovery { + constructor(protected readonly _options: AuthWebhookDiscovery.Options) {} + + /** + * @param {string} namespace - Immutable namespace ID + * @param {AuthWebhookDiscovery.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authWebhookDiscovery.authWebhooksMetadata("namespace") + */ + public async authWebhooksMetadata( + namespace: string, + requestOptions?: AuthWebhookDiscovery.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `namespaces/${encodeURIComponent(namespace)}/auth/webhooks`, + ), + method: "GET", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthWebhookMetadata.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError( + "Timeout exceeded when calling GET /namespaces/{namespace}/auth/webhooks.", + ); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + /** + * @param {string} namespace - Immutable namespace ID + * @param {AuthWebhookDiscovery.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authWebhookDiscovery.authWebhooksJwks("namespace") + */ + public async authWebhooksJwks( + namespace: string, + requestOptions?: AuthWebhookDiscovery.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `namespaces/${encodeURIComponent(namespace)}/auth/webhooks/jwks.json`, + ), + method: "GET", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthWebhookJwks.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError( + "Timeout exceeded when calling GET /namespaces/{namespace}/auth/webhooks/jwks.json.", + ); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; + } +} diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/client/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/client/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/client/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/index.ts new file mode 100644 index 0000000000..5ec76921e1 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookDiscovery/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/Client.ts new file mode 100644 index 0000000000..788fd69ad9 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/Client.ts @@ -0,0 +1,311 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as core from "../../../../core"; +import * as Rivet from "../../../index"; +import urlJoin from "url-join"; +import * as serializers from "../../../../serialization/index"; +import * as errors from "../../../../errors/index"; + +export declare namespace AuthWebhookKeys { + export interface Options { + environment: core.Supplier; + /** Specify a custom URL to connect the client to. */ + baseUrl?: core.Supplier; + token?: core.Supplier; + fetcher?: core.FetchFunction; + } + + export interface RequestOptions { + /** The maximum time to wait for a response in seconds. */ + timeoutInSeconds?: number; + /** The number of times to retry the request. Defaults to 2. */ + maxRetries?: number; + /** A hook to abort the request. */ + abortSignal?: AbortSignal; + /** Additional headers to include in the request. */ + headers?: Record; + } +} + +export class AuthWebhookKeys { + constructor(protected readonly _options: AuthWebhookKeys.Options) {} + + /** + * @param {AuthWebhookKeys.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authWebhookKeys.authWebhooksEnsure() + */ + public async authWebhooksEnsure( + requestOptions?: AuthWebhookKeys.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + "auth/webhooks/ensure", + ), + method: "POST", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthWebhookMetadata.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError("Timeout exceeded when calling POST /auth/webhooks/ensure."); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + /** + * @param {string} namespace - Namespace name + * @param {AuthWebhookKeys.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authWebhookKeys.get("namespace") + */ + public async get( + namespace: string, + requestOptions?: AuthWebhookKeys.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `namespaces/${encodeURIComponent(namespace)}/auth/webhook-keys`, + ), + method: "GET", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthWebhookKeyStatus.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError( + "Timeout exceeded when calling GET /namespaces/{namespace}/auth/webhook-keys.", + ); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + /** + * @param {string} namespace - Namespace name + * @param {Rivet.AuthWebhookRevokeRequest} request + * @param {AuthWebhookKeys.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authWebhookKeys.revoke("namespace", { + * expectedGeneration: 1000000, + * kids: ["kids"], + * requestId: "request_id" + * }) + */ + public async revoke( + namespace: string, + request: Rivet.AuthWebhookRevokeRequest, + requestOptions?: AuthWebhookKeys.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `namespaces/${encodeURIComponent(namespace)}/auth/webhook-keys/revoke`, + ), + method: "POST", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + body: serializers.AuthWebhookRevokeRequest.jsonOrThrow(request, { unrecognizedObjectKeys: "strip" }), + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthWebhookKeyStatus.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError( + "Timeout exceeded when calling POST /namespaces/{namespace}/auth/webhook-keys/revoke.", + ); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + /** + * @param {string} namespace - Namespace name + * @param {Rivet.AuthWebhookRotateRequest} request + * @param {AuthWebhookKeys.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authWebhookKeys.rotate("namespace", { + * expectedGeneration: 1000000 + * }) + */ + public async rotate( + namespace: string, + request: Rivet.AuthWebhookRotateRequest, + requestOptions?: AuthWebhookKeys.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + `namespaces/${encodeURIComponent(namespace)}/auth/webhook-keys/rotate`, + ), + method: "POST", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/json", + requestType: "json", + body: serializers.AuthWebhookRotateRequest.jsonOrThrow(request, { unrecognizedObjectKeys: "strip" }), + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthWebhookKeyStatus.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError( + "Timeout exceeded when calling POST /namespaces/{namespace}/auth/webhook-keys/rotate.", + ); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; + } +} diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/index.ts new file mode 100644 index 0000000000..415726b7fe --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/index.ts @@ -0,0 +1 @@ +export * from "./requests"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/AuthWebhookRevokeRequest.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/AuthWebhookRevokeRequest.ts new file mode 100644 index 0000000000..23b1fc1ab5 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/AuthWebhookRevokeRequest.ts @@ -0,0 +1,17 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +/** + * @example + * { + * expectedGeneration: 1000000, + * kids: ["kids"], + * requestId: "request_id" + * } + */ +export interface AuthWebhookRevokeRequest { + expectedGeneration: number; + kids: string[]; + requestId: string; +} diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/AuthWebhookRotateRequest.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/AuthWebhookRotateRequest.ts new file mode 100644 index 0000000000..3b89a81b74 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/AuthWebhookRotateRequest.ts @@ -0,0 +1,13 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +/** + * @example + * { + * expectedGeneration: 1000000 + * } + */ +export interface AuthWebhookRotateRequest { + expectedGeneration: number; +} diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/index.ts new file mode 100644 index 0000000000..c99e10aba8 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/client/requests/index.ts @@ -0,0 +1,2 @@ +export { type AuthWebhookRevokeRequest } from "./AuthWebhookRevokeRequest"; +export { type AuthWebhookRotateRequest } from "./AuthWebhookRotateRequest"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/index.ts new file mode 100644 index 0000000000..5ec76921e1 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookKeys/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/client/Client.ts new file mode 100644 index 0000000000..56f517e5fc --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/client/Client.ts @@ -0,0 +1,110 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as core from "../../../../core"; +import * as Rivet from "../../../index"; +import * as fs from "fs"; +import { Blob } from "buffer"; +import urlJoin from "url-join"; +import * as serializers from "../../../../serialization/index"; +import * as errors from "../../../../errors/index"; + +export declare namespace AuthWebhookSigning { + export interface Options { + environment: core.Supplier; + /** Specify a custom URL to connect the client to. */ + baseUrl?: core.Supplier; + token?: core.Supplier; + fetcher?: core.FetchFunction; + } + + export interface RequestOptions { + /** The maximum time to wait for a response in seconds. */ + timeoutInSeconds?: number; + /** The number of times to retry the request. Defaults to 2. */ + maxRetries?: number; + /** A hook to abort the request. */ + abortSignal?: AbortSignal; + /** Additional headers to include in the request. */ + headers?: Record; + } +} + +export class AuthWebhookSigning { + constructor(protected readonly _options: AuthWebhookSigning.Options) {} + + /** + * @param {File | fs.ReadStream | Blob} bytes + * @param {AuthWebhookSigning.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.authWebhookSigning.authWebhooksSign(fs.createReadStream("/path/to/your/file")) + */ + public async authWebhooksSign( + bytes: File | fs.ReadStream | Blob, + requestOptions?: AuthWebhookSigning.RequestOptions, + ): Promise { + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: urlJoin( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + "auth/webhooks/sign", + ), + method: "POST", + headers: { + Authorization: await this._getAuthorizationHeader(), + "X-Fern-Language": "JavaScript", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + ...requestOptions?.headers, + }, + contentType: "application/octet-stream", + requestType: "bytes", + duplex: "half", + body: bytes, + timeoutMs: requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 180000, + maxRetries: requestOptions?.maxRetries, + abortSignal: requestOptions?.abortSignal, + }); + if (_response.ok) { + return serializers.AuthWebhookSignResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }); + } + + if (_response.error.reason === "status-code") { + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + }); + } + + switch (_response.error.reason) { + case "non-json": + throw new errors.RivetError({ + statusCode: _response.error.statusCode, + body: _response.error.rawBody, + }); + case "timeout": + throw new errors.RivetTimeoutError("Timeout exceeded when calling POST /auth/webhooks/sign."); + case "unknown": + throw new errors.RivetError({ + message: _response.error.errorMessage, + }); + } + } + + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; + } +} diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/client/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/client/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/client/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/index.ts b/engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/index.ts new file mode 100644 index 0000000000..5ec76921e1 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/resources/authWebhookSigning/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/engine/sdks/typescript/api-full/src/api/resources/datacenters/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/datacenters/client/Client.ts index bced27b262..96c48078a7 100644 --- a/engine/sdks/typescript/api-full/src/api/resources/datacenters/client/Client.ts +++ b/engine/sdks/typescript/api-full/src/api/resources/datacenters/client/Client.ts @@ -13,7 +13,7 @@ export declare namespace Datacenters { environment: core.Supplier; /** Specify a custom URL to connect the client to. */ baseUrl?: core.Supplier; - token: core.Supplier; + token?: core.Supplier; fetcher?: core.FetchFunction; } @@ -91,7 +91,12 @@ export class Datacenters { } } - protected async _getAuthorizationHeader(): Promise { - return `Bearer ${await core.Supplier.get(this._options.token)}`; + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; } } diff --git a/engine/sdks/typescript/api-full/src/api/resources/envoys/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/envoys/client/Client.ts index 6cc35c651b..f9b8f3f4b0 100644 --- a/engine/sdks/typescript/api-full/src/api/resources/envoys/client/Client.ts +++ b/engine/sdks/typescript/api-full/src/api/resources/envoys/client/Client.ts @@ -13,7 +13,7 @@ export declare namespace Envoys { environment: core.Supplier; /** Specify a custom URL to connect the client to. */ baseUrl?: core.Supplier; - token: core.Supplier; + token?: core.Supplier; fetcher?: core.FetchFunction; } @@ -121,7 +121,12 @@ export class Envoys { } } - protected async _getAuthorizationHeader(): Promise { - return `Bearer ${await core.Supplier.get(this._options.token)}`; + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; } } diff --git a/engine/sdks/typescript/api-full/src/api/resources/health/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/health/client/Client.ts index 5e8c9429a1..d44f833fc8 100644 --- a/engine/sdks/typescript/api-full/src/api/resources/health/client/Client.ts +++ b/engine/sdks/typescript/api-full/src/api/resources/health/client/Client.ts @@ -13,7 +13,7 @@ export declare namespace Health { environment: core.Supplier; /** Specify a custom URL to connect the client to. */ baseUrl?: core.Supplier; - token: core.Supplier; + token?: core.Supplier; fetcher?: core.FetchFunction; } @@ -91,7 +91,12 @@ export class Health { } } - protected async _getAuthorizationHeader(): Promise { - return `Bearer ${await core.Supplier.get(this._options.token)}`; + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; } } diff --git a/engine/sdks/typescript/api-full/src/api/resources/metadata/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/metadata/client/Client.ts index 34c78c32cf..ddb1001c00 100644 --- a/engine/sdks/typescript/api-full/src/api/resources/metadata/client/Client.ts +++ b/engine/sdks/typescript/api-full/src/api/resources/metadata/client/Client.ts @@ -13,7 +13,7 @@ export declare namespace Metadata { environment: core.Supplier; /** Specify a custom URL to connect the client to. */ baseUrl?: core.Supplier; - token: core.Supplier; + token?: core.Supplier; fetcher?: core.FetchFunction; } @@ -91,7 +91,12 @@ export class Metadata { } } - protected async _getAuthorizationHeader(): Promise { - return `Bearer ${await core.Supplier.get(this._options.token)}`; + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; } } diff --git a/engine/sdks/typescript/api-full/src/api/resources/namespaces/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/namespaces/client/Client.ts index 2d4234f1c8..e66a0fbcfa 100644 --- a/engine/sdks/typescript/api-full/src/api/resources/namespaces/client/Client.ts +++ b/engine/sdks/typescript/api-full/src/api/resources/namespaces/client/Client.ts @@ -13,7 +13,7 @@ export declare namespace Namespaces { environment: core.Supplier; /** Specify a custom URL to connect the client to. */ baseUrl?: core.Supplier; - token: core.Supplier; + token?: core.Supplier; fetcher?: core.FetchFunction; } @@ -191,7 +191,12 @@ export class Namespaces { } } - protected async _getAuthorizationHeader(): Promise { - return `Bearer ${await core.Supplier.get(this._options.token)}`; + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; } } diff --git a/engine/sdks/typescript/api-full/src/api/resources/runners/client/Client.ts b/engine/sdks/typescript/api-full/src/api/resources/runners/client/Client.ts index 5fd7ebf056..0221b539cd 100644 --- a/engine/sdks/typescript/api-full/src/api/resources/runners/client/Client.ts +++ b/engine/sdks/typescript/api-full/src/api/resources/runners/client/Client.ts @@ -13,7 +13,7 @@ export declare namespace Runners { environment: core.Supplier; /** Specify a custom URL to connect the client to. */ baseUrl?: core.Supplier; - token: core.Supplier; + token?: core.Supplier; fetcher?: core.FetchFunction; } @@ -213,7 +213,12 @@ export class Runners { } } - protected async _getAuthorizationHeader(): Promise { - return `Bearer ${await core.Supplier.get(this._options.token)}`; + protected async _getAuthorizationHeader(): Promise { + const bearer = await core.Supplier.get(this._options.token); + if (bearer != null) { + return `Bearer ${bearer}`; + } + + return undefined; } } diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthApplicationTokenCreateResponse.ts b/engine/sdks/typescript/api-full/src/api/types/AuthApplicationTokenCreateResponse.ts new file mode 100644 index 0000000000..6586e980b9 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthApplicationTokenCreateResponse.ts @@ -0,0 +1,9 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +export interface AuthApplicationTokenCreateResponse { + expiresTs: number; + issuedTs: number; + token: string; +} diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthIssuer.ts b/engine/sdks/typescript/api-full/src/api/types/AuthIssuer.ts new file mode 100644 index 0000000000..102241105c --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthIssuer.ts @@ -0,0 +1,14 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as Rivet from "../index"; + +export interface AuthIssuer { + algorithm: string; + audience: string; + grantCeiling: Rivet.AuthIssuerGrant[]; + issuer: string; + jwksUrl: string; + name: string; +} diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthIssuerDeleteResponse.ts b/engine/sdks/typescript/api-full/src/api/types/AuthIssuerDeleteResponse.ts new file mode 100644 index 0000000000..7f423e372f --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthIssuerDeleteResponse.ts @@ -0,0 +1,5 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +export type AuthIssuerDeleteResponse = Record; diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthIssuerGetResponse.ts b/engine/sdks/typescript/api-full/src/api/types/AuthIssuerGetResponse.ts new file mode 100644 index 0000000000..961306e7dd --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthIssuerGetResponse.ts @@ -0,0 +1,9 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as Rivet from "../index"; + +export interface AuthIssuerGetResponse { + issuer: Rivet.AuthIssuer; +} diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthIssuerGrant.ts b/engine/sdks/typescript/api-full/src/api/types/AuthIssuerGrant.ts new file mode 100644 index 0000000000..2bce50c098 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthIssuerGrant.ts @@ -0,0 +1,11 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as Rivet from "../index"; + +export interface AuthIssuerGrant { + operations: Rivet.AclOperationKindKind[]; + resource: Rivet.AclResourceKind; + target: Rivet.AclTargetScope; +} diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthIssuerListResponse.ts b/engine/sdks/typescript/api-full/src/api/types/AuthIssuerListResponse.ts new file mode 100644 index 0000000000..fe98c58890 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthIssuerListResponse.ts @@ -0,0 +1,9 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as Rivet from "../index"; + +export interface AuthIssuerListResponse { + issuers: Rivet.AuthIssuer[]; +} diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthIssuerUpsertResponse.ts b/engine/sdks/typescript/api-full/src/api/types/AuthIssuerUpsertResponse.ts new file mode 100644 index 0000000000..c7326eb72f --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthIssuerUpsertResponse.ts @@ -0,0 +1,9 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as Rivet from "../index"; + +export interface AuthIssuerUpsertResponse { + issuer: Rivet.AuthIssuer; +} diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthWebhookJwks.ts b/engine/sdks/typescript/api-full/src/api/types/AuthWebhookJwks.ts new file mode 100644 index 0000000000..10f04ddce7 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthWebhookJwks.ts @@ -0,0 +1,9 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as Rivet from "../index"; + +export interface AuthWebhookJwks { + keys: Rivet.AuthWebhookPublicKey[]; +} diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthWebhookKeyStatus.ts b/engine/sdks/typescript/api-full/src/api/types/AuthWebhookKeyStatus.ts new file mode 100644 index 0000000000..c0bcedeae7 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthWebhookKeyStatus.ts @@ -0,0 +1,11 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +export interface AuthWebhookKeyStatus { + activeKid: string; + generation: number; + lastEmergencyGeneration?: number; + lastEmergencyRequestId?: string; + pendingKid?: string; +} diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthWebhookMetadata.ts b/engine/sdks/typescript/api-full/src/api/types/AuthWebhookMetadata.ts new file mode 100644 index 0000000000..4f39e906be --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthWebhookMetadata.ts @@ -0,0 +1,13 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as Rivet from "../index"; + +export interface AuthWebhookMetadata { + alg: string; + generation: number; + jwksUrl: string; + kid: string; + namespaceId: Rivet.RivetId; +} diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthWebhookPublicKey.ts b/engine/sdks/typescript/api-full/src/api/types/AuthWebhookPublicKey.ts new file mode 100644 index 0000000000..caf4b22f89 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthWebhookPublicKey.ts @@ -0,0 +1,12 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +export interface AuthWebhookPublicKey { + alg: string; + crv: string; + kid: string; + kty: string; + use: string; + x: string; +} diff --git a/engine/sdks/typescript/api-full/src/api/types/AuthWebhookSignResponse.ts b/engine/sdks/typescript/api-full/src/api/types/AuthWebhookSignResponse.ts new file mode 100644 index 0000000000..1096bdbf2e --- /dev/null +++ b/engine/sdks/typescript/api-full/src/api/types/AuthWebhookSignResponse.ts @@ -0,0 +1,11 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +export interface AuthWebhookSignResponse { + kid: string; + /** Unpadded base64url Ed25519 signature of timestamp + '.' + raw request body. */ + signature: string; + /** Canonical decimal Unix seconds, generated by the control plane. */ + timestamp: string; +} diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/index.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/index.ts new file mode 100644 index 0000000000..415726b7fe --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/index.ts @@ -0,0 +1 @@ +export * from "./requests"; diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/requests/AuthApplicationTokenCreateRequest.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/requests/AuthApplicationTokenCreateRequest.ts new file mode 100644 index 0000000000..f52d1b5a4d --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/requests/AuthApplicationTokenCreateRequest.ts @@ -0,0 +1,28 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../../../../index"; +import * as Rivet from "../../../../../api/index"; +import * as core from "../../../../../core"; + +export const AuthApplicationTokenCreateRequest: core.serialization.Schema< + serializers.AuthApplicationTokenCreateRequest.Raw, + Rivet.AuthApplicationTokenCreateRequest +> = core.serialization.object({ + audience: core.serialization.string(), + claims: core.serialization.record(core.serialization.string(), core.serialization.unknown()), + duration: core.serialization.number().optional(), + purpose: core.serialization.string(), + subject: core.serialization.string().optional(), +}); + +export declare namespace AuthApplicationTokenCreateRequest { + export interface Raw { + audience: string; + claims: Record; + duration?: number | null; + purpose: string; + subject?: string | null; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/requests/index.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/requests/index.ts new file mode 100644 index 0000000000..50c4361f5b --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/client/requests/index.ts @@ -0,0 +1 @@ +export { AuthApplicationTokenCreateRequest } from "./AuthApplicationTokenCreateRequest"; diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/index.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/index.ts new file mode 100644 index 0000000000..5ec76921e1 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authApplicationTokens/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/index.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/index.ts new file mode 100644 index 0000000000..415726b7fe --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/index.ts @@ -0,0 +1 @@ +export * from "./requests"; diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/requests/AuthIssuerUpsertRequest.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/requests/AuthIssuerUpsertRequest.ts new file mode 100644 index 0000000000..67d870570c --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/requests/AuthIssuerUpsertRequest.ts @@ -0,0 +1,29 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../../../../index"; +import * as Rivet from "../../../../../api/index"; +import * as core from "../../../../../core"; +import { AuthIssuerGrant } from "../../../../types/AuthIssuerGrant"; + +export const AuthIssuerUpsertRequest: core.serialization.Schema< + serializers.AuthIssuerUpsertRequest.Raw, + Rivet.AuthIssuerUpsertRequest +> = core.serialization.object({ + algorithm: core.serialization.string(), + audience: core.serialization.string(), + grantCeiling: core.serialization.property("grant_ceiling", core.serialization.list(AuthIssuerGrant)), + issuer: core.serialization.string(), + jwksUrl: core.serialization.property("jwks_url", core.serialization.string()), +}); + +export declare namespace AuthIssuerUpsertRequest { + export interface Raw { + algorithm: string; + audience: string; + grant_ceiling: AuthIssuerGrant.Raw[]; + issuer: string; + jwks_url: string; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/requests/index.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/requests/index.ts new file mode 100644 index 0000000000..d372985d57 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/client/requests/index.ts @@ -0,0 +1 @@ +export { AuthIssuerUpsertRequest } from "./AuthIssuerUpsertRequest"; diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/index.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/index.ts new file mode 100644 index 0000000000..5ec76921e1 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authIssuers/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/index.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/index.ts new file mode 100644 index 0000000000..415726b7fe --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/index.ts @@ -0,0 +1 @@ +export * from "./requests"; diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/AuthWebhookRevokeRequest.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/AuthWebhookRevokeRequest.ts new file mode 100644 index 0000000000..12b156062c --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/AuthWebhookRevokeRequest.ts @@ -0,0 +1,24 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../../../../index"; +import * as Rivet from "../../../../../api/index"; +import * as core from "../../../../../core"; + +export const AuthWebhookRevokeRequest: core.serialization.Schema< + serializers.AuthWebhookRevokeRequest.Raw, + Rivet.AuthWebhookRevokeRequest +> = core.serialization.object({ + expectedGeneration: core.serialization.property("expected_generation", core.serialization.number()), + kids: core.serialization.list(core.serialization.string()), + requestId: core.serialization.property("request_id", core.serialization.string()), +}); + +export declare namespace AuthWebhookRevokeRequest { + export interface Raw { + expected_generation: number; + kids: string[]; + request_id: string; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/AuthWebhookRotateRequest.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/AuthWebhookRotateRequest.ts new file mode 100644 index 0000000000..4bc9d1814e --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/AuthWebhookRotateRequest.ts @@ -0,0 +1,20 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../../../../index"; +import * as Rivet from "../../../../../api/index"; +import * as core from "../../../../../core"; + +export const AuthWebhookRotateRequest: core.serialization.Schema< + serializers.AuthWebhookRotateRequest.Raw, + Rivet.AuthWebhookRotateRequest +> = core.serialization.object({ + expectedGeneration: core.serialization.property("expected_generation", core.serialization.number()), +}); + +export declare namespace AuthWebhookRotateRequest { + export interface Raw { + expected_generation: number; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/index.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/index.ts new file mode 100644 index 0000000000..1a270ef699 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/client/requests/index.ts @@ -0,0 +1,2 @@ +export { AuthWebhookRevokeRequest } from "./AuthWebhookRevokeRequest"; +export { AuthWebhookRotateRequest } from "./AuthWebhookRotateRequest"; diff --git a/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/index.ts b/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/index.ts new file mode 100644 index 0000000000..5ec76921e1 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/resources/authWebhookKeys/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthApplicationTokenCreateResponse.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthApplicationTokenCreateResponse.ts new file mode 100644 index 0000000000..b96947edab --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthApplicationTokenCreateResponse.ts @@ -0,0 +1,24 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; + +export const AuthApplicationTokenCreateResponse: core.serialization.ObjectSchema< + serializers.AuthApplicationTokenCreateResponse.Raw, + Rivet.AuthApplicationTokenCreateResponse +> = core.serialization.object({ + expiresTs: core.serialization.property("expires_ts", core.serialization.number()), + issuedTs: core.serialization.property("issued_ts", core.serialization.number()), + token: core.serialization.string(), +}); + +export declare namespace AuthApplicationTokenCreateResponse { + export interface Raw { + expires_ts: number; + issued_ts: number; + token: string; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuer.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuer.ts new file mode 100644 index 0000000000..ba0263cfc8 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuer.ts @@ -0,0 +1,29 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; +import { AuthIssuerGrant } from "./AuthIssuerGrant"; + +export const AuthIssuer: core.serialization.ObjectSchema = + core.serialization.object({ + algorithm: core.serialization.string(), + audience: core.serialization.string(), + grantCeiling: core.serialization.property("grant_ceiling", core.serialization.list(AuthIssuerGrant)), + issuer: core.serialization.string(), + jwksUrl: core.serialization.property("jwks_url", core.serialization.string()), + name: core.serialization.string(), + }); + +export declare namespace AuthIssuer { + export interface Raw { + algorithm: string; + audience: string; + grant_ceiling: AuthIssuerGrant.Raw[]; + issuer: string; + jwks_url: string; + name: string; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerDeleteResponse.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerDeleteResponse.ts new file mode 100644 index 0000000000..6e128bbeb1 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerDeleteResponse.ts @@ -0,0 +1,16 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; + +export const AuthIssuerDeleteResponse: core.serialization.Schema< + serializers.AuthIssuerDeleteResponse.Raw, + Rivet.AuthIssuerDeleteResponse +> = core.serialization.record(core.serialization.string(), core.serialization.unknown()); + +export declare namespace AuthIssuerDeleteResponse { + export type Raw = Record; +} diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerGetResponse.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerGetResponse.ts new file mode 100644 index 0000000000..6468aae3b4 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerGetResponse.ts @@ -0,0 +1,21 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; +import { AuthIssuer } from "./AuthIssuer"; + +export const AuthIssuerGetResponse: core.serialization.ObjectSchema< + serializers.AuthIssuerGetResponse.Raw, + Rivet.AuthIssuerGetResponse +> = core.serialization.object({ + issuer: AuthIssuer, +}); + +export declare namespace AuthIssuerGetResponse { + export interface Raw { + issuer: AuthIssuer.Raw; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerGrant.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerGrant.ts new file mode 100644 index 0000000000..72e10443cd --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerGrant.ts @@ -0,0 +1,25 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; +import { AclOperationKindKind } from "./AclOperationKindKind"; +import { AclResourceKind } from "./AclResourceKind"; +import { AclTargetScope } from "./AclTargetScope"; + +export const AuthIssuerGrant: core.serialization.ObjectSchema = + core.serialization.object({ + operations: core.serialization.list(AclOperationKindKind), + resource: AclResourceKind, + target: AclTargetScope, + }); + +export declare namespace AuthIssuerGrant { + export interface Raw { + operations: AclOperationKindKind.Raw[]; + resource: AclResourceKind.Raw; + target: AclTargetScope.Raw; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerListResponse.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerListResponse.ts new file mode 100644 index 0000000000..ce7e40f558 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerListResponse.ts @@ -0,0 +1,21 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; +import { AuthIssuer } from "./AuthIssuer"; + +export const AuthIssuerListResponse: core.serialization.ObjectSchema< + serializers.AuthIssuerListResponse.Raw, + Rivet.AuthIssuerListResponse +> = core.serialization.object({ + issuers: core.serialization.list(AuthIssuer), +}); + +export declare namespace AuthIssuerListResponse { + export interface Raw { + issuers: AuthIssuer.Raw[]; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerUpsertResponse.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerUpsertResponse.ts new file mode 100644 index 0000000000..d492a3f85c --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthIssuerUpsertResponse.ts @@ -0,0 +1,21 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; +import { AuthIssuer } from "./AuthIssuer"; + +export const AuthIssuerUpsertResponse: core.serialization.ObjectSchema< + serializers.AuthIssuerUpsertResponse.Raw, + Rivet.AuthIssuerUpsertResponse +> = core.serialization.object({ + issuer: AuthIssuer, +}); + +export declare namespace AuthIssuerUpsertResponse { + export interface Raw { + issuer: AuthIssuer.Raw; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookJwks.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookJwks.ts new file mode 100644 index 0000000000..22ff08a3e5 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookJwks.ts @@ -0,0 +1,19 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; +import { AuthWebhookPublicKey } from "./AuthWebhookPublicKey"; + +export const AuthWebhookJwks: core.serialization.ObjectSchema = + core.serialization.object({ + keys: core.serialization.list(AuthWebhookPublicKey), + }); + +export declare namespace AuthWebhookJwks { + export interface Raw { + keys: AuthWebhookPublicKey.Raw[]; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookKeyStatus.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookKeyStatus.ts new file mode 100644 index 0000000000..5db815fa51 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookKeyStatus.ts @@ -0,0 +1,34 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; + +export const AuthWebhookKeyStatus: core.serialization.ObjectSchema< + serializers.AuthWebhookKeyStatus.Raw, + Rivet.AuthWebhookKeyStatus +> = core.serialization.object({ + activeKid: core.serialization.property("active_kid", core.serialization.string()), + generation: core.serialization.number(), + lastEmergencyGeneration: core.serialization.property( + "last_emergency_generation", + core.serialization.number().optional(), + ), + lastEmergencyRequestId: core.serialization.property( + "last_emergency_request_id", + core.serialization.string().optional(), + ), + pendingKid: core.serialization.property("pending_kid", core.serialization.string().optional()), +}); + +export declare namespace AuthWebhookKeyStatus { + export interface Raw { + active_kid: string; + generation: number; + last_emergency_generation?: number | null; + last_emergency_request_id?: string | null; + pending_kid?: string | null; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookMetadata.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookMetadata.ts new file mode 100644 index 0000000000..816c372f93 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookMetadata.ts @@ -0,0 +1,29 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; +import { RivetId } from "./RivetId"; + +export const AuthWebhookMetadata: core.serialization.ObjectSchema< + serializers.AuthWebhookMetadata.Raw, + Rivet.AuthWebhookMetadata +> = core.serialization.object({ + alg: core.serialization.string(), + generation: core.serialization.number(), + jwksUrl: core.serialization.property("jwks_url", core.serialization.string()), + kid: core.serialization.string(), + namespaceId: core.serialization.property("namespace_id", RivetId), +}); + +export declare namespace AuthWebhookMetadata { + export interface Raw { + alg: string; + generation: number; + jwks_url: string; + kid: string; + namespace_id: RivetId.Raw; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookPublicKey.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookPublicKey.ts new file mode 100644 index 0000000000..80703df8e8 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookPublicKey.ts @@ -0,0 +1,30 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; + +export const AuthWebhookPublicKey: core.serialization.ObjectSchema< + serializers.AuthWebhookPublicKey.Raw, + Rivet.AuthWebhookPublicKey +> = core.serialization.object({ + alg: core.serialization.string(), + crv: core.serialization.string(), + kid: core.serialization.string(), + kty: core.serialization.string(), + use: core.serialization.string(), + x: core.serialization.string(), +}); + +export declare namespace AuthWebhookPublicKey { + export interface Raw { + alg: string; + crv: string; + kid: string; + kty: string; + use: string; + x: string; + } +} diff --git a/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookSignResponse.ts b/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookSignResponse.ts new file mode 100644 index 0000000000..68f30abaa0 --- /dev/null +++ b/engine/sdks/typescript/api-full/src/serialization/types/AuthWebhookSignResponse.ts @@ -0,0 +1,24 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../index"; +import * as Rivet from "../../api/index"; +import * as core from "../../core"; + +export const AuthWebhookSignResponse: core.serialization.ObjectSchema< + serializers.AuthWebhookSignResponse.Raw, + Rivet.AuthWebhookSignResponse +> = core.serialization.object({ + kid: core.serialization.string(), + signature: core.serialization.string(), + timestamp: core.serialization.string(), +}); + +export declare namespace AuthWebhookSignResponse { + export interface Raw { + kid: string; + signature: string; + timestamp: string; + } +} diff --git a/engine/sdks/typescript/data/package.json b/engine/sdks/typescript/data/package.json new file mode 100644 index 0000000000..be38ac879b --- /dev/null +++ b/engine/sdks/typescript/data/package.json @@ -0,0 +1,36 @@ +{ + "name": "@rivetkit/engine-data", + "version": "2.3.17", + "license": "SEE LICENSE IN LICENSE", + "type": "module", + "files": ["dist"], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "generate": "bare compile ../../schemas/data/auth.grants.v1.bare -o src/auth-grants-v1.generated.ts", + "build": "tsup src/index.ts", + "check-types": "tsc --noEmit", + "test": "tsx tests/auth-grants.ts" + }, + "dependencies": { + "@bare-ts/lib": "^0.6.0", + "vbare": "^0.0.4" + }, + "devDependencies": { + "@bare-ts/tools": "^0.15.0", + "@types/node": "^22.13.9", + "tsx": "^4.20.0", + "tsup": "^8.5.0", + "typescript": "^5.9.2" + } +} diff --git a/engine/sdks/typescript/data/src/auth-grants-v1.generated.ts b/engine/sdks/typescript/data/src/auth-grants-v1.generated.ts new file mode 100644 index 0000000000..129667fef0 --- /dev/null +++ b/engine/sdks/typescript/data/src/auth-grants-v1.generated.ts @@ -0,0 +1,275 @@ +import * as bare from "@bare-ts/lib" + +const DEFAULT_CONFIG = /* @__PURE__ */ bare.Config({}) + +export type Any = null + +export type Id = ArrayBuffer + +export function readId(bc: bare.ByteCursor): Id { + return bare.readData(bc) +} + +export function writeId(bc: bare.ByteCursor, x: Id): void { + bare.writeData(bc, x) +} + +export type TargetScope = + | { readonly tag: "Any"; readonly val: Any } + | { readonly tag: "Id"; readonly val: Id } + +export function readTargetScope(bc: bare.ByteCursor): TargetScope { + const offset = bc.offset + const tag = bare.readU8(bc) + switch (tag) { + case 0: + return { tag: "Any", val: null } + case 1: + return { tag: "Id", val: readId(bc) } + default: { + bc.offset = offset + throw new bare.BareError(offset, "invalid tag") + } + } +} + +export function writeTargetScope(bc: bare.ByteCursor, x: TargetScope): void { + switch (x.tag) { + case "Any": { + bare.writeU8(bc, 0) + break + } + case "Id": { + bare.writeU8(bc, 1) + writeId(bc, x.val) + break + } + } +} + +export enum ResourceKind { + Namespace = "Namespace", + Actor = "Actor", + Runner = "Runner", + RunnerConfig = "RunnerConfig", + Token = "Token", + Reserved = "Reserved", + Datacenter = "Datacenter", + ActorGateway = "ActorGateway", + ActorKv = "ActorKv", +} + +export function readResourceKind(bc: bare.ByteCursor): ResourceKind { + const offset = bc.offset + const tag = bare.readU8(bc) + switch (tag) { + case 0: + return ResourceKind.Namespace + case 1: + return ResourceKind.Actor + case 2: + return ResourceKind.Runner + case 3: + return ResourceKind.RunnerConfig + case 4: + return ResourceKind.Token + case 5: + return ResourceKind.Reserved + case 6: + return ResourceKind.Datacenter + case 7: + return ResourceKind.ActorGateway + case 8: + return ResourceKind.ActorKv + default: { + bc.offset = offset + throw new bare.BareError(offset, "invalid tag") + } + } +} + +export function writeResourceKind(bc: bare.ByteCursor, x: ResourceKind): void { + switch (x) { + case ResourceKind.Namespace: { + bare.writeU8(bc, 0) + break + } + case ResourceKind.Actor: { + bare.writeU8(bc, 1) + break + } + case ResourceKind.Runner: { + bare.writeU8(bc, 2) + break + } + case ResourceKind.RunnerConfig: { + bare.writeU8(bc, 3) + break + } + case ResourceKind.Token: { + bare.writeU8(bc, 4) + break + } + case ResourceKind.Reserved: { + bare.writeU8(bc, 5) + break + } + case ResourceKind.Datacenter: { + bare.writeU8(bc, 6) + break + } + case ResourceKind.ActorGateway: { + bare.writeU8(bc, 7) + break + } + case ResourceKind.ActorKv: { + bare.writeU8(bc, 8) + break + } + } +} + +export enum OperationKind { + Read = "Read", + Update = "Update", + List = "List", + Create = "Create", + Delete = "Delete", +} + +export function readOperationKind(bc: bare.ByteCursor): OperationKind { + const offset = bc.offset + const tag = bare.readU8(bc) + switch (tag) { + case 0: + return OperationKind.Read + case 1: + return OperationKind.Update + case 2: + return OperationKind.List + case 3: + return OperationKind.Create + case 4: + return OperationKind.Delete + default: { + bc.offset = offset + throw new bare.BareError(offset, "invalid tag") + } + } +} + +export function writeOperationKind(bc: bare.ByteCursor, x: OperationKind): void { + switch (x) { + case OperationKind.Read: { + bare.writeU8(bc, 0) + break + } + case OperationKind.Update: { + bare.writeU8(bc, 1) + break + } + case OperationKind.List: { + bare.writeU8(bc, 2) + break + } + case OperationKind.Create: { + bare.writeU8(bc, 3) + break + } + case OperationKind.Delete: { + bare.writeU8(bc, 4) + break + } + } +} + +function read0(bc: bare.ByteCursor): readonly OperationKind[] { + const len = bare.readUintSafe(bc) + if (len === 0) { + return [] + } + const result = [readOperationKind(bc)] + for (let i = 1; i < len; i++) { + result[i] = readOperationKind(bc) + } + return result +} + +function write0(bc: bare.ByteCursor, x: readonly OperationKind[]): void { + bare.writeUintSafe(bc, x.length) + for (let i = 0; i < x.length; i++) { + writeOperationKind(bc, x[i]) + } +} + +export type Grant = { + readonly resource: ResourceKind + readonly target: TargetScope + readonly operations: readonly OperationKind[] +} + +export function readGrant(bc: bare.ByteCursor): Grant { + return { + resource: readResourceKind(bc), + target: readTargetScope(bc), + operations: read0(bc), + } +} + +export function writeGrant(bc: bare.ByteCursor, x: Grant): void { + writeResourceKind(bc, x.resource) + writeTargetScope(bc, x.target) + write0(bc, x.operations) +} + +function read1(bc: bare.ByteCursor): readonly Grant[] { + const len = bare.readUintSafe(bc) + if (len === 0) { + return [] + } + const result = [readGrant(bc)] + for (let i = 1; i < len; i++) { + result[i] = readGrant(bc) + } + return result +} + +function write1(bc: bare.ByteCursor, x: readonly Grant[]): void { + bare.writeUintSafe(bc, x.length) + for (let i = 0; i < x.length; i++) { + writeGrant(bc, x[i]) + } +} + +export type Data = { + readonly grants: readonly Grant[] +} + +export function readData(bc: bare.ByteCursor): Data { + return { + grants: read1(bc), + } +} + +export function writeData(bc: bare.ByteCursor, x: Data): void { + write1(bc, x.grants) +} + +export function encodeData(x: Data, config?: Partial): Uint8Array { + const fullConfig = config != null ? bare.Config(config) : DEFAULT_CONFIG + const bc = new bare.ByteCursor( + new Uint8Array(fullConfig.initialBufferLength), + fullConfig, + ) + writeData(bc, x) + return new Uint8Array(bc.view.buffer, bc.view.byteOffset, bc.offset) +} + +export function decodeData(bytes: Uint8Array): Data { + const bc = new bare.ByteCursor(bytes, DEFAULT_CONFIG) + const result = readData(bc) + if (bc.offset < bc.view.byteLength) { + throw new bare.BareError(bc.offset, "remaining bytes") + } + return result +} diff --git a/engine/sdks/typescript/data/src/index.ts b/engine/sdks/typescript/data/src/index.ts new file mode 100644 index 0000000000..2349246689 --- /dev/null +++ b/engine/sdks/typescript/data/src/index.ts @@ -0,0 +1,151 @@ +import { createVersionedDataHandler } from "vbare"; +import { + type Data, + decodeData, + encodeData, + type Grant, + OperationKind, + ResourceKind, + type TargetScope, +} from "./auth-grants-v1.generated"; + +export { + type Data as AuthGrantsData, + OperationKind as AuthGrantOperationKind, + ResourceKind as AuthGrantResourceKind, +} from "./auth-grants-v1.generated"; + +export const AUTH_GRANTS_VERSION = 1; + +const MAX_GRANTS = 32; +const MAX_OPERATIONS_PER_GRANT = 5; +const MAX_GRANTS_BYTES = 2_048; + +// This order matches the Rust authorization enums and is part of the signed v1 wire format. +const RESOURCE_ORDER = [ + ResourceKind.Namespace, + ResourceKind.Actor, + ResourceKind.Runner, + ResourceKind.RunnerConfig, + ResourceKind.Token, + ResourceKind.Reserved, + ResourceKind.Datacenter, + ResourceKind.ActorGateway, + ResourceKind.ActorKv, +] as const; +const OPERATION_ORDER = [ + OperationKind.Read, + OperationKind.Update, + OperationKind.List, + OperationKind.Create, + OperationKind.Delete, +] as const; + +function rank(order: readonly T[], value: T): number { + const index = order.indexOf(value); + if (index < 0) throw new Error("Invalid auth grant variant"); + return index; +} + +function normalizeTarget(target: TargetScope): TargetScope { + if (target.tag === "Any") return { tag: "Any", val: null }; + if (target.tag !== "Id" || !(target.val instanceof ArrayBuffer)) + throw new Error("Invalid auth grant target"); + const bytes = new Uint8Array(target.val); + if (bytes.length !== 19 || bytes[0] !== 1) + throw new Error("Invalid auth grant target ID"); + return { tag: "Id", val: bytes.slice().buffer }; +} + +function compareSelector(a: Grant, b: Grant): number { + const resource = + rank(RESOURCE_ORDER, a.resource) - rank(RESOURCE_ORDER, b.resource); + if (resource !== 0) return resource; + if (a.target.tag !== b.target.tag) return a.target.tag === "Any" ? -1 : 1; + if (a.target.tag === "Any" || b.target.tag === "Any") return 0; + const left = new Uint8Array(a.target.val); + const right = new Uint8Array(b.target.val); + for (let index = 1; index < left.length; index++) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +function canonicalize(data: Data): Data { + if ( + !Array.isArray(data.grants) || + data.grants.length === 0 || + data.grants.length > MAX_GRANTS + ) + throw new Error("Invalid auth grant count"); + const sorted = data.grants.map((grant): Grant => { + if ( + grant.resource === ResourceKind.Token || + grant.resource === ResourceKind.Reserved + ) + throw new Error("Control-plane auth grants are not allowed"); + rank(RESOURCE_ORDER, grant.resource); + if ( + !Array.isArray(grant.operations) || + grant.operations.length === 0 || + grant.operations.length > MAX_OPERATIONS_PER_GRANT + ) + throw new Error("Invalid auth grant operations"); + for (const operation of grant.operations) + rank(OPERATION_ORDER, operation); + return { + resource: grant.resource, + target: normalizeTarget(grant.target), + operations: [...new Set(grant.operations)].sort( + (a, b) => rank(OPERATION_ORDER, a) - rank(OPERATION_ORDER, b), + ), + }; + }); + sorted.sort(compareSelector); + const grants: Grant[] = []; + for (const grant of sorted) { + const previous = grants.at(-1); + if (previous && compareSelector(previous, grant) === 0) { + const operations = [ + ...new Set([...previous.operations, ...grant.operations]), + ].sort( + (a, b) => rank(OPERATION_ORDER, a) - rank(OPERATION_ORDER, b), + ); + if (operations.length > MAX_OPERATIONS_PER_GRANT) + throw new Error("Too many auth grant operations"); + grants[grants.length - 1] = { ...previous, operations }; + } else { + grants.push(grant); + } + } + return { grants }; +} + +const grantsData = createVersionedDataHandler({ + deserializeVersion: (bytes, version) => { + if (version !== AUTH_GRANTS_VERSION) + throw new Error("Unsupported grants version"); + return decodeData(bytes); + }, + serializeVersion: (data, version) => { + if (version !== AUTH_GRANTS_VERSION) + throw new Error("Unsupported grants version"); + return encodeData(data); + }, + deserializeConverters: () => [], + serializeConverters: () => [], +}); + +/** + * Canonicalizes and encodes the versioned BARE payload for `rivet_grants`. + * The token's signed `rivet_ns` claim supplies the namespace; grants do not embed it. + */ +export function encodeAuthGrants(data: Data): Uint8Array { + const encoded = grantsData.serializeWithEmbeddedVersion( + canonicalize(data), + AUTH_GRANTS_VERSION, + ); + if (encoded.length > MAX_GRANTS_BYTES) + throw new Error("Auth grants exceed the maximum encoded size"); + return encoded; +} diff --git a/engine/sdks/typescript/data/tests/auth-grants.ts b/engine/sdks/typescript/data/tests/auth-grants.ts new file mode 100644 index 0000000000..9301ff4482 --- /dev/null +++ b/engine/sdks/typescript/data/tests/auth-grants.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { + AuthGrantOperationKind, + AuthGrantResourceKind, + encodeAuthGrants, +} from "../src/index"; + +assert.deepEqual( + [ + ...encodeAuthGrants({ + grants: [ + { + resource: AuthGrantResourceKind.ActorGateway, + target: { tag: "Any", val: null }, + operations: [AuthGrantOperationKind.Read], + }, + ], + }), + ], + [1, 0, 1, 7, 0, 1, 0], +); + +const idA = Uint8Array.from([1, 1, ...Array(17).fill(0)]).buffer; +const idB = Uint8Array.from([1, 2, ...Array(17).fill(0)]).buffer; +const canonical = encodeAuthGrants({ + grants: [ + { + resource: AuthGrantResourceKind.Runner, + target: { tag: "Any", val: null }, + operations: [AuthGrantOperationKind.Delete], + }, + { + resource: AuthGrantResourceKind.Actor, + target: { tag: "Id", val: idB }, + operations: [AuthGrantOperationKind.Create], + }, + { + resource: AuthGrantResourceKind.Actor, + target: { tag: "Id", val: idA }, + operations: [ + AuthGrantOperationKind.List, + AuthGrantOperationKind.Read, + AuthGrantOperationKind.Read, + ], + }, + { + resource: AuthGrantResourceKind.Actor, + target: { tag: "Any", val: null }, + operations: [ + AuthGrantOperationKind.Update, + AuthGrantOperationKind.Read, + ], + }, + { + resource: AuthGrantResourceKind.Actor, + target: { tag: "Id", val: idA }, + operations: [AuthGrantOperationKind.Update], + }, + ], +}); +assert.equal( + Buffer.from(canonical).toString("base64url"), + "AQAEAQACAAEBARMBAQAAAAAAAAAAAAAAAAAAAAAAAwABAgEBEwECAAAAAAAAAAAAAAAAAAAAAAABAwIAAQQ", +); + +assert.throws(() => encodeAuthGrants({ grants: [] })); +assert.throws(() => + encodeAuthGrants({ + grants: [ + { + resource: AuthGrantResourceKind.Token, + target: { tag: "Any", val: null }, + operations: [AuthGrantOperationKind.Create], + }, + ], + }), +); +assert.throws(() => + encodeAuthGrants({ + grants: [ + { + resource: AuthGrantResourceKind.Actor, + target: { tag: "Id", val: new ArrayBuffer(18) }, + operations: [AuthGrantOperationKind.Read], + }, + ], + }), +); +assert.throws(() => + encodeAuthGrants({ + grants: [ + { + resource: AuthGrantResourceKind.Actor, + target: { tag: "Any", val: null }, + operations: Array(6).fill(AuthGrantOperationKind.Read), + }, + ], + }), +); + +console.log("Versioned auth grants encoding passed"); + +assert.throws(() => + encodeAuthGrants({ + grants: [ + { + resource: AuthGrantResourceKind.Reserved, + target: { tag: "Any", val: null }, + operations: [AuthGrantOperationKind.Read], + }, + ], + }), +); diff --git a/engine/sdks/typescript/data/tsconfig.json b/engine/sdks/typescript/data/tsconfig.json new file mode 100644 index 0000000000..0331d3f71c --- /dev/null +++ b/engine/sdks/typescript/data/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "./dist" + }, + "exclude": ["dist", "node_modules"], + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/engine/sdks/typescript/data/tsup.config.ts b/engine/sdks/typescript/data/tsup.config.ts new file mode 100644 index 0000000000..2d399b5efe --- /dev/null +++ b/engine/sdks/typescript/data/tsup.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from "tsup"; +import defaultConfig from "../../../../tsup.base"; + +export default defineConfig(defaultConfig); diff --git a/examples/jwks-counter/.gitignore b/examples/jwks-counter/.gitignore new file mode 100644 index 0000000000..2bdfcc6614 --- /dev/null +++ b/examples/jwks-counter/.gitignore @@ -0,0 +1,3 @@ +*.pem +*.key +.env* diff --git a/examples/jwks-counter/README.md b/examples/jwks-counter/README.md new file mode 100644 index 0000000000..4df6e6d53d --- /dev/null +++ b/examples/jwks-counter/README.md @@ -0,0 +1,30 @@ +# Externally signed JWKS counter + +This counter uses RivetKit's renewable `getToken` hook, but the backend signs the JWT with **its own Ed25519 key**. Rivet trusts the exact issuer, audience, public JWKS URL, namespace, algorithm, and maximum grant set registered by a namespace administrator. The backend publishes public verification keys only; clients receive short-lived actor-specific JWTs, never an ACL token or private key. + +## Setup + +Use an Engine with JWT verification and namespace ACL enabled, and a disposable namespace. The actor host uses its usual server-side RivetKit runtime credentials. Give a **provisioning-only** `RIVET_PROVISION_TOKEN` permission to create/read actors, read `actor_gateway` targets, and update `jwt` issuer registrations in this namespace. It does not need JWT issuance or global namespace-read permission. Keep this token out of the running JWT issuer and all clients. + +1. Generate an Ed25519 PKCS#8 key outside the repository, for example `key_dir=$(mktemp -d); openssl genpkey -algorithm Ed25519 -out "$key_dir/private.pem"; export JWKS_PRIVATE_KEY_PATH="$key_dir/private.pem"`. Never commit it. +2. Set `JWKS_ISSUER=https://jwks-counter.gameinc.io` and expose this example server through HTTPS on port 443 at `JWKS_PUBLIC_URL=https://jwks-counter.gameinc.io/.well-known/jwks.json`. The domain must resolve publicly and the URL must not redirect; Engine deliberately rejects localhost, IP literals, redirects, credential-bearing URLs, and non-443 ports. The HTTP server listens on port 3021 behind your TLS proxy/tunnel. +3. On the provisioning machine, set `RIVET_ENDPOINT` (your Engine's HTTPS API endpoint), `RIVET_NAMESPACE` (name), `RIVET_NAMESPACE_ID` (ID from your namespace settings), `RIVET_PROVISION_TOKEN`, `DEMO_USER`, `JWKS_ISSUER`, and `JWKS_PUBLIC_URL`. Run `pnpm --filter jwks-counter-example provision`. This creates the private counter actor, encodes versioned BARE grants with the shared TypeScript data codec, then registers `PUT /namespaces/{namespace}/auth/issuers/counter-demo` with: + + ```json + { + "issuer": "https://jwks-counter.gameinc.io", + "audience": "rivet-api", + "jwks_url": "https://jwks-counter.gameinc.io/.well-known/jwks.json", + "algorithm": "EdDSA", + "grant_ceiling": [{ "resource": "actor_gateway", "target": { "id": "" }, "operations": ["read"] }] + } + ``` + +4. Set the resulting `JWKS_ACTOR_ID`, `JWKS_NAMESPACE_ID`, `JWKS_GRANTS`, and (for negative smoke tests only) `JWKS_EXCESS_GRANTS` in the backend environment. These grant bytes alone cannot authenticate a request without the signing key. Set a strong `DEMO_PASSWORD` for the authenticated single-user demo issuer. **Unset `RIVET_PROVISION_TOKEN` in the runtime backend.** Run `pnpm --filter jwks-counter-example start` and verify that `GET /.well-known/jwks.json` includes the public JWK but no `d` field. +5. Run `pnpm --filter jwks-counter-example client` with `RIVET_ENDPOINT`, `RIVET_NAMESPACE`, `JWKS_ISSUER`, `JWKS_ACTOR_ID`, `DEMO_USER`, and `DEMO_PASSWORD` (no private key, no provisioning token). The client calls `/token` as necessary, then reads and increments its counter through RivetKit. Basic auth is only for this CLI demo: use HTTPS outside loopback and integrate your production user/session authentication for a browser client. + +To check signing independently of Engine, run `pnpm --filter jwks-counter-example exec tsx tests/crypto.ts` with the backend issuer environment. With the backend and Engine running, `pnpm --filter jwks-counter-example smoke` verifies valid counter use and rejects expired tokens, wrong audience, wrong namespace, and grants above the ceiling. To also test issuer removal, use **only** a disposable namespace named `jwks-smoke-*` and opt in with `JWKS_TEST_REMOVE_ISSUER=1` plus `RIVET_PROVISION_TOKEN` and `JWKS_PUBLIC_URL`. This deletes the registration, waits for the verifier cache, asserts rejection, and re-registers it in a `finally` block. Do not run the destructive check on a shared namespace. + +## Rotating keys + +Keep old public keys available until every token they signed expires (30 seconds in this demo, plus 30 seconds of verifier clock skew). To rotate, generate the next key outside Git; first publish its **public-only** JWK in `JWKS_ADDITIONAL_PUBLIC_KEYS` alongside the current key and wait at least one 30-second verifier cache lifetime. Switch `JWKS_PRIVATE_KEY_PATH` to the next private key while retaining the old public JWK in that same public-only list. After the old tokens and skew expire, remove the old public JWK. Never serve a private JWK (`d`) or print the private key. The issuer's EdDSA algorithm and `kid` must match the published JWK. diff --git a/examples/jwks-counter/package.json b/examples/jwks-counter/package.json new file mode 100644 index 0000000000..facbef9bfa --- /dev/null +++ b/examples/jwks-counter/package.json @@ -0,0 +1,25 @@ +{ + "name": "jwks-counter-example", + "version": "1.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "start": "tsx src/server.ts --host 0.0.0.0", + "client": "tsx src/client.ts", + "provision": "tsx src/provision.ts", + "test:grants": "tsx tests/grants.ts", + "smoke": "tsx tests/smoke.ts", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@rivetkit/engine-data": "workspace:*", + "jose": "^6.1.3", + "rivetkit": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "tsx": "^4.20.0", + "typescript": "^5.5.2" + } +} diff --git a/examples/jwks-counter/src/client.ts b/examples/jwks-counter/src/client.ts new file mode 100644 index 0000000000..a5e1e7aaad --- /dev/null +++ b/examples/jwks-counter/src/client.ts @@ -0,0 +1,53 @@ +import { createClient } from "rivetkit/client"; +import { endpoint, required } from "./config.ts"; +import type { registry } from "./counter.ts"; + +export async function counterClient() { + const authorization = `Basic ${Buffer.from(`${required("DEMO_USER")}:${required("DEMO_PASSWORD")}`).toString("base64")}`; + let actorId: string | undefined; + const client = createClient({ + endpoint: endpoint(), + namespace: required("RIVET_NAMESPACE"), + disableMetadataLookup: true, + getToken: async () => { + const response = await fetch( + new URL("/token", required("JWKS_ISSUER")), + { + method: "POST", + headers: { Authorization: authorization }, + cache: "no-store", + }, + ); + if (!response.ok) + throw new Error( + `authentication failed: HTTP ${response.status}`, + ); + const issued = (await response.json()) as { + token: string; + actorId: string; + }; + actorId = issued.actorId; + return issued.token; + }, + }); + // The backend returns the ID with the initial signed JWT; the client never + // gains actor.create or actor.list permissions to discover it itself. + const first = await client.counter + .getForId(required("JWKS_ACTOR_ID")) + .getCount(); + if (!actorId || actorId !== required("JWKS_ACTOR_ID")) + throw new Error("issuer actor identity mismatch"); + const counter = client.counter.getForId(actorId); + return { + initial: first, + incremented: await counter.increment(1), + current: await counter.getCount(), + }; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const result = await counterClient(); + console.log( + `Counter: ${result.initial} → ${result.incremented} → ${result.current}`, + ); +} diff --git a/examples/jwks-counter/src/config.ts b/examples/jwks-counter/src/config.ts new file mode 100644 index 0000000000..da9a65fc03 --- /dev/null +++ b/examples/jwks-counter/src/config.ts @@ -0,0 +1,19 @@ +export function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} must be configured`); + return value; +} + +export function endpoint(): string { + const value = required("RIVET_ENDPOINT"); + if ( + !value.startsWith("https://") && + !value.startsWith("http://127.0.0.1:") && + !value.startsWith("http://localhost:") + ) { + throw new Error( + "RIVET_ENDPOINT must use HTTPS except for a local Engine", + ); + } + return value; +} diff --git a/examples/jwks-counter/src/counter.ts b/examples/jwks-counter/src/counter.ts new file mode 100644 index 0000000000..ca41902b0b --- /dev/null +++ b/examples/jwks-counter/src/counter.ts @@ -0,0 +1,14 @@ +import { actor, setup } from "rivetkit"; + +export const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount: number) => { + c.state.count += amount; + return c.state.count; + }, + getCount: (c) => c.state.count, + }, +}); + +export const registry = setup({ use: { counter } }); diff --git a/examples/jwks-counter/src/grants.ts b/examples/jwks-counter/src/grants.ts new file mode 100644 index 0000000000..109cf2c2fe --- /dev/null +++ b/examples/jwks-counter/src/grants.ts @@ -0,0 +1,40 @@ +import { + AuthGrantOperationKind, + AuthGrantResourceKind, + type AuthGrantsData, + encodeAuthGrants, +} from "@rivetkit/engine-data"; + +// Rivet IDs are 19-byte, little-endian base36 values; the first byte is the ID version. +function idBytes(id: string): ArrayBuffer { + if (!/^[0-9a-z]{30}$/i.test(id)) throw new Error("Invalid Rivet ID"); + let value = 0n; + for (const digit of id.toLowerCase().split("").reverse()) { + value = value * 36n + BigInt(Number.parseInt(digit, 36)); + } + const bytes = new Uint8Array(19); + for (let index = 0; index < bytes.length; index++) { + bytes[index] = Number(value & 0xffn); + value >>= 8n; + } + if (value !== 0n || bytes[0] !== 1) throw new Error("Unsupported Rivet ID"); + return bytes.buffer; +} + +export function encodeActorGatewayReadGrant( + target: "any" | { id: string }, +): string { + const data: AuthGrantsData = { + grants: [ + { + resource: AuthGrantResourceKind.ActorGateway, + target: + target === "any" + ? { tag: "Any", val: null } + : { tag: "Id", val: idBytes(target.id) }, + operations: [AuthGrantOperationKind.Read], + }, + ], + }; + return Buffer.from(encodeAuthGrants(data)).toString("base64url"); +} diff --git a/examples/jwks-counter/src/issuer.ts b/examples/jwks-counter/src/issuer.ts new file mode 100644 index 0000000000..afb0097841 --- /dev/null +++ b/examples/jwks-counter/src/issuer.ts @@ -0,0 +1,128 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + randomBytes, + timingSafeEqual, +} from "node:crypto"; +import { readFileSync } from "node:fs"; +import { exportJWK, importPKCS8, type JWK, SignJWT } from "jose"; +import { required } from "./config.ts"; + +export const issuer = required("JWKS_ISSUER"); +export const audience = "rivet-api"; +export const actorId = required("JWKS_ACTOR_ID"); +export const namespaceId = required("JWKS_NAMESPACE_ID"); +export const grants = required("JWKS_GRANTS"); +const username = required("DEMO_USER"); +const password = required("DEMO_PASSWORD"); + +// Load private signing material only on the server; never copy it into a client +// bundle or JWKS response. Additional keys must be public-only JWKs. +const pem = readFileSync(required("JWKS_PRIVATE_KEY_PATH"), "utf8"); +const publicKey = createPublicKey(createPrivateKey(pem)); +const signer = await importPKCS8(pem, "EdDSA"); +const currentPublic = await exportJWK(publicKey); +if ( + currentPublic.kty !== "OKP" || + currentPublic.crv !== "Ed25519" || + !currentPublic.x +) { + throw new Error("Expected an Ed25519 PKCS#8 signing key"); +} +const kid = createHash("sha256") + .update(currentPublic.x) + .digest() + .subarray(0, 16) + .toString("base64url"); + +export function publicJwks(): { keys: JWK[] } { + const additional = JSON.parse( + process.env.JWKS_ADDITIONAL_PUBLIC_KEYS ?? "[]", + ) as JWK[]; + const allowed = new Set([ + "kty", + "crv", + "x", + "kid", + "use", + "alg", + "key_ops", + ]); + if ( + !Array.isArray(additional) || + additional.length > 15 || + additional.some( + (key) => + key === null || + typeof key !== "object" || + key.kty !== "OKP" || + key.crv !== "Ed25519" || + typeof key.x !== "string" || + Buffer.from(key.x, "base64url").length !== 32 || + Buffer.from(key.x, "base64url").toString("base64url") !== + key.x || + typeof key.kid !== "string" || + !key.kid || + key.kid.length > 128 || + !/^[\x21-\x7e]+$/.test(key.kid) || + key.alg !== "EdDSA" || + key.use !== "sig" || + (key.key_ops !== undefined && + (!Array.isArray(key.key_ops) || + key.key_ops.length !== 1 || + key.key_ops[0] !== "verify")) || + Object.keys(key).some((field) => !allowed.has(field)), + ) || + new Set([kid, ...additional.map((key) => key.kid)]).size !== + additional.length + 1 + ) { + throw new Error( + "JWKS_ADDITIONAL_PUBLIC_KEYS must contain at most 15 public Ed25519 JWKs", + ); + } + return { + keys: [ + { ...currentPublic, kid, use: "sig", alg: "EdDSA" }, + ...additional, + ], + }; +} + +export function authenticate(header?: string): boolean { + if (!header) return false; + const expected = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; + return timingSafeEqual( + createHash("sha256").update(header).digest(), + createHash("sha256").update(expected).digest(), + ); +} + +export async function signCounterToken( + options: { + grants?: string; + audience?: string; + namespaceId?: string; + issuedAt?: number; + expiresAt?: number; + } = {}, +): Promise { + // Never issue a token when its public verification keys cannot be served. + publicJwks(); + const now = Math.floor(Date.now() / 1_000); + const issuedAt = options.issuedAt ?? now; + const expiresAt = options.expiresAt ?? now + 30; + return new SignJWT({ + rivet_ver: 1, + rivet_ns: options.namespaceId ?? namespaceId, + rivet_grants: options.grants ?? grants, + }) + .setProtectedHeader({ typ: "rivet-auth+jwt", alg: "EdDSA", kid }) + .setIssuer(issuer) + .setAudience(options.audience ?? audience) + .setSubject(username) + .setIssuedAt(issuedAt) + .setExpirationTime(expiresAt) + .setJti(randomBytes(16).toString("base64url")) + .sign(signer); +} diff --git a/examples/jwks-counter/src/provision.ts b/examples/jwks-counter/src/provision.ts new file mode 100644 index 0000000000..79b1efbf06 --- /dev/null +++ b/examples/jwks-counter/src/provision.ts @@ -0,0 +1,72 @@ +import { createClient } from "rivetkit/client"; +import { endpoint, required } from "./config.ts"; +import type { registry } from "./counter.ts"; +import { encodeActorGatewayReadGrant } from "./grants.ts"; + +const engine = endpoint(); +const namespace = required("RIVET_NAMESPACE"); +const namespaceId = required("RIVET_NAMESPACE_ID"); +// ACL management is used only during provisioning, not to sign end-user JWTs. +const token = required("RIVET_PROVISION_TOKEN"); +const issuer = required("JWKS_ISSUER"); +const jwksUrl = required("JWKS_PUBLIC_URL"); +const audience = "rivet-api"; +const issuerName = "counter-demo"; + +const manager = createClient({ + endpoint: engine, + namespace, + token, + disableMetadataLookup: true, +}); +const actorId = await manager.counter + .getOrCreate(["user", required("DEMO_USER")]) + .resolve(); + +const scoped = encodeActorGatewayReadGrant({ id: actorId }); +const excess = encodeActorGatewayReadGrant("any"); +const registration = { + issuer, + audience, + jwks_url: jwksUrl, + algorithm: "EdDSA", + grant_ceiling: [ + { + resource: "actor_gateway", + target: { id: actorId }, + operations: ["read"], + }, + ], +}; +const url = new URL( + `/namespaces/${encodeURIComponent(namespace)}/auth/issuers/${issuerName}`, + engine, +); +const registered = await fetch(url, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(registration), +}); +if (!registered.ok) + throw new Error(`issuer registration failed: HTTP ${registered.status}`); + +// None of these fields is a credential without the private signing key. Never +// print the ACL token or private key itself. +console.log( + JSON.stringify( + { + JWKS_ACTOR_ID: actorId, + JWKS_NAMESPACE_ID: namespaceId, + JWKS_GRANTS: scoped, + JWKS_EXCESS_GRANTS: excess, + issuer, + audience, + jwksUrl, + }, + null, + 2, + ), +); diff --git a/examples/jwks-counter/src/server.ts b/examples/jwks-counter/src/server.ts new file mode 100644 index 0000000000..d249fd6093 --- /dev/null +++ b/examples/jwks-counter/src/server.ts @@ -0,0 +1,60 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { registry } from "./counter.ts"; +import { + actorId, + authenticate, + publicJwks, + signCounterToken, +} from "./issuer.ts"; + +export async function handleRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + res.setHeader("Content-Type", "application/json"); + if (req.method === "GET" && req.url === "/.well-known/jwks.json") { + try { + const jwks = publicJwks(); + res.setHeader("Cache-Control", "public, max-age=30"); + res.writeHead(200).end(JSON.stringify(jwks)); + } catch { + res.setHeader("Cache-Control", "no-store"); + res.writeHead(503).end( + JSON.stringify({ error: "issuer_unavailable" }), + ); + } + return; + } + res.setHeader("Cache-Control", "no-store"); + if (req.method !== "POST" || req.url !== "/token") { + res.writeHead(404).end(JSON.stringify({ error: "not_found" })); + return; + } + if (!authenticate(req.headers.authorization)) { + res.writeHead(401, { + "WWW-Authenticate": 'Basic realm="JWKS counter"', + }).end(JSON.stringify({ error: "unauthorized" })); + return; + } + try { + const token = await signCounterToken(); + res.writeHead(200).end(JSON.stringify({ actorId, token })); + } catch { + res.writeHead(503).end(JSON.stringify({ error: "issuer_unavailable" })); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + registry.start(); + const host = + process.argv.indexOf("--host") >= 0 + ? process.argv[process.argv.indexOf("--host") + 1] + : "127.0.0.1"; + createServer((req, res) => { + void handleRequest(req, res); + }).listen(3021, host); +} diff --git a/examples/jwks-counter/tests/crypto.ts b/examples/jwks-counter/tests/crypto.ts new file mode 100644 index 0000000000..9e472a4686 --- /dev/null +++ b/examples/jwks-counter/tests/crypto.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { createHash, generateKeyPairSync } from "node:crypto"; +import { decodeProtectedHeader, importJWK, jwtVerify } from "jose"; +import { + audience, + issuer, + publicJwks, + signCounterToken, +} from "../src/issuer.ts"; + +const { keys } = publicJwks(); +assert.equal(keys.length >= 1, true); +const publicJwk = keys[0]; +if (!publicJwk) throw new Error("missing public signing key"); +assert.equal("d" in publicJwk, false, "never publish a private JWK component"); +const key = await importJWK(publicJwk, "EdDSA"); +const token = await signCounterToken(); +assert.equal(decodeProtectedHeader(token).typ, "rivet-auth+jwt"); +assert.equal(decodeProtectedHeader(token).kid, keys[0]?.kid); +assert.deepEqual(Object.keys(decodeProtectedHeader(token)).sort(), [ + "alg", + "kid", + "typ", +]); +const verified = await jwtVerify(token, key, { + issuer, + audience, + algorithms: ["EdDSA"], +}); +assert.equal(verified.payload.rivet_ver, 1); +assert.equal(typeof verified.payload.rivet_grants, "string"); +assert.deepEqual(Object.keys(verified.payload).sort(), [ + "aud", + "exp", + "iat", + "iss", + "jti", + "rivet_grants", + "rivet_ns", + "rivet_ver", + "sub", +]); + +const nextKey = generateKeyPairSync("ed25519").publicKey.export({ + format: "jwk", +}); +if (!nextKey.x) throw new Error("missing generated Ed25519 public component"); +const nextKid = createHash("sha256") + .update(nextKey.x) + .digest() + .subarray(0, 16) + .toString("base64url"); +process.env.JWKS_ADDITIONAL_PUBLIC_KEYS = JSON.stringify([ + { ...nextKey, kid: nextKid, use: "sig", alg: "EdDSA" }, +]); +assert.equal( + publicJwks().keys.length, + 2, + "stage a public key before rotating the signer", +); +process.env.JWKS_ADDITIONAL_PUBLIC_KEYS = JSON.stringify([ + { ...nextKey, kid: nextKid, use: "sig", alg: "EdDSA", d: "private" }, +]); +assert.throws(() => publicJwks(), /public Ed25519 JWKs/); +delete process.env.JWKS_ADDITIONAL_PUBLIC_KEYS; +console.log("External JWT signing and public-only JWKS verification passed"); diff --git a/examples/jwks-counter/tests/grants.ts b/examples/jwks-counter/tests/grants.ts new file mode 100644 index 0000000000..ec319316b9 --- /dev/null +++ b/examples/jwks-counter/tests/grants.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { encodeActorGatewayReadGrant } from "../src/grants.ts"; + +const any = Buffer.from(encodeActorGatewayReadGrant("any"), "base64url"); +assert.deepEqual([...any], [1, 0, 1, 7, 0, 1, 0]); + +// Rivet's version-1 nil ID is a 1 followed by 29 zeroes in its little-endian base36 form. +const id = `1${"0".repeat(29)}`; +const scoped = Buffer.from(encodeActorGatewayReadGrant({ id }), "base64url"); +assert.deepEqual( + [...scoped], + [1, 0, 1, 7, 1, 19, 1, ...Array(18).fill(0), 1, 0], +); +assert.equal( + encodeActorGatewayReadGrant({ id: id.toUpperCase() }), + encodeActorGatewayReadGrant({ id }), +); +assert.throws(() => encodeActorGatewayReadGrant({ id: "not-a-rivet-id" })); + +console.log("JWKS grant encoding passed"); diff --git a/examples/jwks-counter/tests/server.ts b/examples/jwks-counter/tests/server.ts new file mode 100644 index 0000000000..a23c88f039 --- /dev/null +++ b/examples/jwks-counter/tests/server.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { decodeJwt } from "jose"; +import { handleRequest } from "../src/server.ts"; + +const server = createServer((req, res) => { + void handleRequest(req, res); +}); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +try { + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("missing test listener"); + const base = `http://127.0.0.1:${address.port}`; + const jwks = await fetch(`${base}/.well-known/jwks.json`); + assert.equal(jwks.status, 200); + assert.equal(jwks.headers.get("cache-control"), "public, max-age=30"); + const { keys } = (await jwks.json()) as { keys: Record[] }; + assert.equal(keys.length, 1); + const publicJwk = keys[0]; + if (!publicJwk) throw new Error("missing public signing key"); + assert.equal("d" in publicJwk, false); + const authorized = () => + fetch(`${base}/token`, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${process.env.DEMO_USER}:${process.env.DEMO_PASSWORD}`).toString("base64")}`, + }, + }); + process.env.JWKS_ADDITIONAL_PUBLIC_KEYS = "not-json"; + try { + const unavailable = await fetch(`${base}/.well-known/jwks.json`); + assert.equal(unavailable.status, 503); + assert.equal(unavailable.headers.get("cache-control"), "no-store"); + const unavailableToken = await authorized(); + assert.equal(unavailableToken.status, 503); + } finally { + delete process.env.JWKS_ADDITIONAL_PUBLIC_KEYS; + } + assert.equal( + (await fetch(`${base}/token`, { method: "POST" })).status, + 401, + ); + const first = await authorized(); + assert.equal(first.status, 200); + assert.equal(first.headers.get("cache-control"), "no-store"); + const second = await authorized(); + const firstToken = ((await first.json()) as { token: string }).token; + const secondToken = ((await second.json()) as { token: string }).token; + assert.notEqual(decodeJwt(firstToken).jti, decodeJwt(secondToken).jti); + console.log( + "JWKS issuer HTTP test passed: public-only JWKS and authenticated token renewal", + ); +} finally { + server.close(); +} diff --git a/examples/jwks-counter/tests/smoke.ts b/examples/jwks-counter/tests/smoke.ts new file mode 100644 index 0000000000..41af1151a4 --- /dev/null +++ b/examples/jwks-counter/tests/smoke.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { createClient } from "rivetkit/client"; +import { counterClient } from "../src/client.ts"; +import { endpoint, required } from "../src/config.ts"; +import type { registry } from "../src/counter.ts"; +import { actorId, audience, issuer, signCounterToken } from "../src/issuer.ts"; + +const engine = endpoint(); +const namespace = required("RIVET_NAMESPACE"); +const expected = await counterClient(); +assert.equal(expected.incremented, expected.initial + 1); +assert.equal(expected.current, expected.incremented); + +async function expectRejected(token: string, code: string): Promise { + const client = createClient({ + endpoint: engine, + namespace, + disableMetadataLookup: true, + getToken: async () => token, + }); + await assert.rejects( + client.counter.getForId(actorId).getCount(), + (error: unknown) => { + return ( + error instanceof Error && + "group" in error && + error.group === "auth" && + "code" in error && + error.code === code + ); + }, + ); +} + +const now = Math.floor(Date.now() / 1_000); +await expectRejected( + await signCounterToken({ issuedAt: now - 120, expiresAt: now - 60 }), + "token_expired", +); +await expectRejected( + await signCounterToken({ audience: "wrong-audience" }), + "invalid_token", +); +await expectRejected( + await signCounterToken({ namespaceId: randomUUID() }), + "invalid_token", +); +await expectRejected( + await signCounterToken({ grants: required("JWKS_EXCESS_GRANTS") }), + "invalid_token", +); + +// This destructive test is opt-in, dedicated to a disposable namespace, and +// restores registration even if an assertion fails. Never use it in production. +if (process.env.JWKS_TEST_REMOVE_ISSUER === "1") { + if (!namespace.startsWith("jwks-smoke-")) + throw new Error( + "Use a dedicated jwks-smoke-* namespace for issuer deletion tests", + ); + const token = required("RIVET_PROVISION_TOKEN"); + const url = new URL( + `/namespaces/${encodeURIComponent(namespace)}/auth/issuers/counter-demo`, + engine, + ); + const body = { + issuer, + audience, + jwks_url: required("JWKS_PUBLIC_URL"), + algorithm: "EdDSA", + grant_ceiling: [ + { + resource: "actor_gateway", + target: { id: actorId }, + operations: ["read"], + }, + ], + }; + try { + const remove = await fetch(url, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + assert.equal(remove.ok, true); + // The verifier caches external registrations for at most 30 seconds. + await new Promise((resolve) => setTimeout(resolve, 32_000)); + await expectRejected(await signCounterToken(), "invalid_token"); + } finally { + const restore = await fetch(url, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + assert.equal( + restore.ok, + true, + "restore issuer registration before exiting", + ); + } +} +console.log( + "JWKS counter smoke passed: scoped access and issuer/audience/expiry/grant isolation", +); diff --git a/examples/jwks-counter/tsconfig.json b/examples/jwks-counter/tsconfig.json new file mode 100644 index 0000000000..1a90d7a842 --- /dev/null +++ b/examples/jwks-counter/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98c441cd75..d97cbac0a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,31 @@ importers: specifier: 5.8.2 version: 5.8.2 + engine/sdks/typescript/data: + dependencies: + '@bare-ts/lib': + specifier: ^0.6.0 + version: 0.6.0 + vbare: + specifier: ^0.0.4 + version: 0.0.4 + devDependencies: + '@bare-ts/tools': + specifier: ^0.15.0 + version: 0.15.0(@bare-ts/lib@0.6.0) + '@types/node': + specifier: ^22.13.9 + version: 22.19.10 + tsup: + specifier: ^8.5.0 + version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.6.0))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0) + tsx: + specifier: ^4.20.0 + version: 4.23.1 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + engine/sdks/typescript/envoy-protocol: dependencies: '@rivetkit/bare-ts': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3b8b5eb876..2229383582 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ packages: - engine - self-host/compose/template - engine/sdks/typescript/api-full + - engine/sdks/typescript/data - engine/sdks/typescript/envoy-protocol - engine/sdks/typescript/test-envoy - engine/sdks/typescript/test-envoy-native diff --git a/scripts/fern/gen_inner.sh b/scripts/fern/gen_inner.sh index 296bfc5a5e..54190fd230 100755 --- a/scripts/fern/gen_inner.sh +++ b/scripts/fern/gen_inner.sh @@ -7,7 +7,7 @@ set -euf -o pipefail (cd engine/sdks/api && TMPDIR=/tmp FERN_NO_VERSION_REDIRECTION=true FERN_DISABLE_TELEMETRY=true npx -p fern-api@0.44.11 fern generate --local --group $FERN_GROUP --log-level debug) # Add missing deps -(cd engine/sdks/typescript/api-$FERN_GROUP && jq ".devDependencies[\"@types/node-fetch\"] = \"2.6.11\"" package.json > package.json.tmp && mv package.json.tmp package.json) +(cd engine/sdks/typescript/api-$FERN_GROUP && jq '.devDependencies["@types/node-fetch"] = "2.6.11" | .license = "SEE LICENSE IN LICENSE"' package.json > package.json.tmp && mv package.json.tmp package.json) # Generate OpenAPI clients ./scripts/openapi/gen_rust.ts & diff --git a/scripts/openapi/gen_rust.ts b/scripts/openapi/gen_rust.ts index 84b8b70bd2..4023887b43 100755 --- a/scripts/openapi/gen_rust.ts +++ b/scripts/openapi/gen_rust.ts @@ -1,7 +1,9 @@ #!/usr/bin/env tsx import { spawn } from 'node:child_process'; -import { rm, readFile, writeFile } from 'node:fs/promises'; +import { rm, readFile, writeFile, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { promisify } from 'node:util'; const FERN_GROUP = process.env.FERN_GROUP; @@ -34,7 +36,10 @@ async function generateRustSdk() { // Delete existing directories await rm(GEN_PATH_RUST, { recursive: true, force: true }); - const uid = process.getuid?.() ?? 1000; + // The pinned Rust generator requires OpenAPI 3.0 nullable schemas. + const inputDir = await mkdtemp(join(tmpdir(), "rivet-openapi-")); + await runCommand("node", ["scripts/openapi/normalize.mjs", OPENAPI_PATH, join(inputDir, "openapi.json")]); + const uid = process.getuid?.() ?? 1000; const gid = process.getgid?.() ?? 1000; await runCommand("docker", [ @@ -42,11 +47,14 @@ async function generateRustSdk() { "--rm", `-u=${uid}:${gid}`, `-v=${process.cwd()}:/data`, + `-v=${inputDir}:/input:ro`, "openapitools/openapi-generator-cli:v7.14.0", "generate", "-i", - `/data/${OPENAPI_PATH}`, + "/input/openapi.json", "--additional-properties=removeEnumValuePrefix=false", + "--type-mappings", "file=Vec,binary=Vec", + "-t", "/data/scripts/openapi/templates/rust", "-g", "rust", "-o", @@ -117,7 +125,8 @@ async function modifyDependencies() { /\[dependencies\.reqwest\]/, "[dependencies.reqwest]\ndefault-features = false", ); - await writeFile(cargoTomlPath, cargoToml, 'utf-8'); + cargoToml = cargoToml.replace(/^license = .*$/m, 'license = "LicenseRef-RivetEnterprise"'); + await writeFile(cargoTomlPath, cargoToml, 'utf-8'); } async function applyErrorPatch() { diff --git a/scripts/openapi/normalize.mjs b/scripts/openapi/normalize.mjs new file mode 100644 index 0000000000..832ad6184c --- /dev/null +++ b/scripts/openapi/normalize.mjs @@ -0,0 +1,31 @@ +import fs from 'node:fs'; + +// Derive OpenAPI 3.0-compatible nullable schemas for the pinned Rust generator. +const doc = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +function visit(value) { + if (Array.isArray(value)) return value.map(visit); + if (!value || typeof value !== 'object') return value; + let result = Object.fromEntries(Object.entries(value).map(([key, child]) => [key, visit(child)])); + delete result.propertyNames; + for (const union of ['oneOf', 'anyOf']) { + if (result[union]?.some((branch) => branch.type === 'null')) { + const branches = result[union].filter((branch) => branch.type !== 'null'); + delete result[union]; + result = branches.length === 1 + ? branches[0].$ref + ? { ...result, allOf: branches, nullable: true } + : { ...result, ...branches[0], nullable: true } + : { ...result, [union]: branches, nullable: true }; + } + } + if (Array.isArray(result.type) && result.type.includes('null')) { + const types = result.type.filter((type) => type !== 'null'); + result.type = types.length === 1 ? types[0] : types; + result.nullable = true; + } + return result; +} +const normalized = visit(doc); +normalized.openapi = '3.0.3'; +if (normalized.info?.license) delete normalized.info.license.identifier; +fs.writeFileSync(process.argv[3], JSON.stringify(normalized, null, 2)); diff --git a/scripts/openapi/templates/rust/README.md b/scripts/openapi/templates/rust/README.md new file mode 100644 index 0000000000..2a779adff8 --- /dev/null +++ b/scripts/openapi/templates/rust/README.md @@ -0,0 +1,6 @@ +This override is based on OpenAPI Generator v7.14.0's `rust/reqwest/api.mustache`: +https://github.com/OpenAPITools/openapi-generator/blob/v7.14.0/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache + +The upstream template is licensed under Apache-2.0. The sole customization sets +`Content-Type: application/octet-stream` when sending a binary request body. +`gen_rust.ts` maps the binary schema to `Vec` so callers supply exact bytes. diff --git a/scripts/openapi/templates/rust/reqwest/api.mustache b/scripts/openapi/templates/rust/reqwest/api.mustache new file mode 100644 index 0000000000..bb61438b9d --- /dev/null +++ b/scripts/openapi/templates/rust/reqwest/api.mustache @@ -0,0 +1,515 @@ +{{>partial_header}} + +use reqwest; +use serde::{Deserialize, Serialize, de::Error as _}; +use crate::{apis::ResponseContent, models}; +use super::{Error, configuration, ContentType}; + +{{#operations}} +{{#operation}} +{{#vendorExtensions.x-group-parameters}} +{{#allParams}} +{{#-first}} +/// struct for passing parameters to the method [`{{operationId}}`] +#[derive(Clone, Debug)] +pub struct {{{operationIdCamelCase}}}Params { +{{/-first}} + {{#description}} + /// {{{.}}} + {{/description}} + pub {{{paramName}}}: {{! + ### Option Start + }}{{^required}}Option<{{/required}}{{#required}}{{#isNullable}}Option<{{/isNullable}}{{/required}}{{! + ### &str and Vec<&str> + }}{{^isUuid}}{{#isString}}{{#isArray}}Vec<{{/isArray}}String{{#isArray}}>{{/isArray}}{{/isString}}{{/isUuid}}{{! + ### UUIDs + }}{{#isUuid}}{{#isArray}}Vec<{{/isArray}}String{{#isArray}}>{{/isArray}}{{/isUuid}}{{! + ### Models and primative types + }}{{^isString}}{{^isUuid}}{{^isPrimitiveType}}{{^isContainer}}models::{{/isContainer}}{{/isPrimitiveType}}{{{dataType}}}{{/isUuid}}{{/isString}}{{! + ### Option End + }}{{^required}}>{{/required}}{{#required}}{{#isNullable}}>{{/isNullable}}{{/required}}{{! + ### Comma for next arguement + }}{{^-last}},{{/-last}} +{{#-last}} +} + +{{/-last}} +{{/allParams}} +{{/vendorExtensions.x-group-parameters}} +{{/operation}} +{{/operations}} + +{{#supportMultipleResponses}} +{{#operations}} +{{#operation}} +/// struct for typed successes of method [`{{operationId}}`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum {{{operationIdCamelCase}}}Success { + {{#responses}} + {{#is2xx}} + Status{{code}}({{#isEnum}}{{{enumName}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}), + {{/is2xx}} + {{#is3xx}} + Status{{code}}({{#isEnum}}{{{enumName}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}), + {{/is3xx}} + {{/responses}} + UnknownValue(serde_json::Value), +} + +{{/operation}} +{{/operations}} +{{/supportMultipleResponses}} +{{#operations}} +{{#operation}} +/// struct for typed errors of method [`{{operationId}}`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum {{{operationIdCamelCase}}}Error { + {{#responses}} + {{#is4xx}} + Status{{code}}({{#isEnum}}{{{enumName}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}), + {{/is4xx}} + {{#is5xx}} + Status{{code}}({{#isEnum}}{{{enumName}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}), + {{/is5xx}} + {{#isDefault}} + DefaultResponse({{#isEnum}}{{{enumName}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}), + {{/isDefault}} + {{/responses}} + UnknownValue(serde_json::Value), +} + +{{/operation}} +{{/operations}} + +{{#operations}} +{{#operation}} +{{#description}} +/// {{{.}}} +{{/description}} +{{#notes}} +/// {{{.}}} +{{/notes}} +{{#vendorExtensions.x-group-parameters}} +pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration: &configuration::Configuration{{#allParams}}{{#-first}}, {{! +### Params +}}params: {{{operationIdCamelCase}}}Params{{/-first}}{{/allParams}}{{! +### Function return type +}}) -> Result<{{! +### Response File Support +}}{{#isResponseFile}}{{#supportAsync}}reqwest::Response{{/supportAsync}}{{^supportAsync}}reqwest::blocking::Response{{/supportAsync}}{{/isResponseFile}}{{! +### Regular Responses +}}{{^isResponseFile}}{{! +### Multi response support +}}{{#supportMultipleResponses}}ResponseContent<{{{operationIdCamelCase}}}Success>{{/supportMultipleResponses}}{{! +### Regular return type +}}{{^supportMultipleResponses}}{{^returnType}}(){{/returnType}}{{{returnType}}}{{/supportMultipleResponses}}{{/isResponseFile}}{{! +### Error Type +}}, Error<{{{operationIdCamelCase}}}Error>> { +{{/vendorExtensions.x-group-parameters}} +{{^vendorExtensions.x-group-parameters}} +pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration: &configuration::Configuration, {{#allParams}}{{{paramName}}}: {{! +### Option Start +}}{{^required}}Option<{{/required}}{{#required}}{{#isNullable}}Option<{{/isNullable}}{{/required}}{{! +### &str and Vec<&str> +}}{{#isString}}{{#isArray}}Vec<{{/isArray}}{{^isUuid}}&str{{/isUuid}}{{#isArray}}>{{/isArray}}{{/isString}}{{! +### UUIDs +}}{{#isUuid}}{{#isArray}}Vec<{{/isArray}}&str{{#isArray}}>{{/isArray}}{{/isUuid}}{{! +### Models and primative types +}}{{^isString}}{{^isUuid}}{{^isPrimitiveType}}{{^isContainer}}models::{{/isContainer}}{{/isPrimitiveType}}{{{dataType}}}{{/isUuid}}{{/isString}}{{! +### Option End +}}{{^required}}>{{/required}}{{#required}}{{#isNullable}}>{{/isNullable}}{{/required}}{{! +### Comma for next arguement +}}{{^-last}}, {{/-last}}{{/allParams}}{{! +### Function return type +}}) -> Result<{{! +### Response File Support +}}{{#isResponseFile}}{{#supportAsync}}reqwest::Response{{/supportAsync}}{{^supportAsync}}reqwest::blocking::Response{{/supportAsync}}{{/isResponseFile}}{{! +### Regular Responses +}}{{^isResponseFile}}{{! +### Multi response support +}}{{#supportMultipleResponses}}ResponseContent<{{{operationIdCamelCase}}}Success>{{/supportMultipleResponses}}{{! +### Regular return type +}}{{^supportMultipleResponses}}{{^returnType}}(){{/returnType}}{{{returnType}}}{{/supportMultipleResponses}}{{/isResponseFile}}{{! +### Error Type +}}, Error<{{{operationIdCamelCase}}}Error>> { + {{#allParams.0}} + // add a prefix to parameters to efficiently prevent name collisions + {{/allParams.0}} + {{#allParams}} + let {{{vendorExtensions.x-rust-param-identifier}}} = {{{paramName}}}; + {{/allParams}} +{{/vendorExtensions.x-group-parameters}} + + let uri_str = format!("{}{{{path}}}", configuration.base_path{{#pathParams}}, {{{baseName}}}={{#isString}}crate::apis::urlencode({{/isString}}{{{vendorExtensions.x-rust-param-identifier}}}{{^required}}.unwrap(){{/required}}{{#required}}{{#isNullable}}.unwrap(){{/isNullable}}{{/required}}{{#isArray}}.join(",").as_ref(){{/isArray}}{{^isString}}{{^isUuid}}{{^isPrimitiveType}}{{^isContainer}}.to_string(){{/isContainer}}{{/isPrimitiveType}}{{/isUuid}}{{/isString}}{{#isString}}){{/isString}}{{/pathParams}}); + let mut req_builder = configuration.client.request(reqwest::Method::{{{httpMethod}}}, &uri_str); + + {{#queryParams}} + {{#required}} + {{#isArray}} + req_builder = match "{{collectionFormat}}" { + "multi" => req_builder.query(&{{{vendorExtensions.x-rust-param-identifier}}}.into_iter().map(|p| ("{{{baseName}}}".to_owned(), p.to_string())).collect::>()), + _ => req_builder.query(&[("{{{baseName}}}", &{{{vendorExtensions.x-rust-param-identifier}}}.into_iter().map(|p| p.to_string()).collect::>().join(",").to_string())]), + }; + {{/isArray}} + {{^isArray}} + {{^isNullable}} + req_builder = req_builder.query(&[("{{{baseName}}}", &{{{vendorExtensions.x-rust-param-identifier}}}.to_string())]); + {{/isNullable}} + {{#isNullable}} + {{#isDeepObject}} + if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} { + {{^isExplode}} + let params = crate::apis::parse_deep_object("{{{baseName}}}", &serde_json::to_value(param_value)?); + req_builder = req_builder.query(¶ms); + {{/isExplode}} + {{#isExplode}} + {{#isModel}} + req_builder = req_builder.query(¶m_value); + {{/isModel}} + {{#isMap}} + let mut query_params = Vec::with_capacity(param_value.len()); + for (key, value) in param_value.iter() { + query_params.push((key.to_string(), serde_json::to_string(value)?)); + } + req_builder = req_builder.query(&query_params); + {{/isMap}} + {{/isExplode}} + }; + {{/isDeepObject}} + {{^isDeepObject}} + {{#isObject}} + if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} { + req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]); + }; + {{/isObject}} + {{#isModel}} + if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} { + req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]); + }; + {{/isModel}} + {{/isDeepObject}} + {{^isObject}} + {{^isModel}} + if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} { + req_builder = req_builder.query(&[("{{{baseName}}}", ¶m_value.to_string())]); + }; + {{/isModel}} + {{/isObject}} + {{/isNullable}} + {{/isArray}} + {{/required}} + {{^required}} + if let Some(ref param_value) = {{{vendorExtensions.x-rust-param-identifier}}} { + {{#isArray}} + req_builder = match "{{collectionFormat}}" { + "multi" => req_builder.query(¶m_value.into_iter().map(|p| ("{{{baseName}}}".to_owned(), p.to_string())).collect::>()), + _ => req_builder.query(&[("{{{baseName}}}", ¶m_value.into_iter().map(|p| p.to_string()).collect::>().join(",").to_string())]), + }; + {{/isArray}} + {{^isArray}} + {{#isDeepObject}} + {{^isExplode}} + let params = crate::apis::parse_deep_object("{{{baseName}}}", &serde_json::to_value(param_value)?); + req_builder = req_builder.query(¶ms); + {{/isExplode}} + {{#isExplode}} + {{#isModel}} + req_builder = req_builder.query(¶m_value); + {{/isModel}} + {{#isMap}} + let mut query_params = Vec::with_capacity(param_value.len()); + for (key, value) in param_value.iter() { + query_params.push((key.to_string(), serde_json::to_string(value)?)); + } + req_builder = req_builder.query(&query_params); + {{/isMap}} + {{/isExplode}} + {{/isDeepObject}} + {{^isDeepObject}} + {{#isObject}} + req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]); + {{/isObject}} + {{#isModel}} + req_builder = req_builder.query(&[("{{{baseName}}}", &serde_json::to_string(param_value)?)]); + {{/isModel}} + {{^isObject}} + {{^isModel}} + req_builder = req_builder.query(&[("{{{baseName}}}", ¶m_value.to_string())]); + {{/isModel}} + {{/isObject}} + {{/isDeepObject}} + {{/isArray}} + } + {{/required}} + {{/queryParams}} + {{#hasAuthMethods}} + {{#authMethods}} + {{#isApiKey}} + {{#isKeyInQuery}} + if let Some(ref apikey) = configuration.api_key { + let key = apikey.key.clone(); + let value = match apikey.prefix { + Some(ref prefix) => format!("{} {}", prefix, key), + None => key, + }; + req_builder = req_builder.query(&[("{{{keyParamName}}}", value)]); + } + {{/isKeyInQuery}} + {{/isApiKey}} + {{/authMethods}} + {{/hasAuthMethods}} + {{#hasAuthMethods}} + {{#withAWSV4Signature}} + if let Some(ref aws_v4_key) = configuration.aws_v4_key { + let new_headers = match aws_v4_key.sign( + &uri_str, + "{{{httpMethod}}}", + {{#hasBodyParam}} + {{#bodyParams}} + &serde_json::to_string(&{{{vendorExtensions.x-rust-param-identifier}}}).expect("param should serialize to string"), + {{/bodyParams}} + {{/hasBodyParam}} + {{^hasBodyParam}} + "", + {{/hasBodyParam}} + ) { + Ok(new_headers) => new_headers, + Err(err) => return Err(Error::AWSV4SignatureError(err)), + }; + for (name, value) in new_headers.iter() { + req_builder = req_builder.header(name.as_str(), value.as_str()); + } + } + {{/withAWSV4Signature}} + {{/hasAuthMethods}} + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + {{#hasHeaderParams}} + {{#headerParams}} + {{#required}} + {{^isNullable}} + req_builder = req_builder.header("{{{baseName}}}", {{{vendorExtensions.x-rust-param-identifier}}}{{#isArray}}.join(","){{/isArray}}.to_string()); + {{/isNullable}} + {{#isNullable}} + match {{{vendorExtensions.x-rust-param-identifier}}} { + Some(param_value) => { req_builder = req_builder.header("{{{baseName}}}", param_value{{#isArray}}.join(","){{/isArray}}.to_string()); }, + None => { req_builder = req_builder.header("{{{baseName}}}", ""); }, + } + {{/isNullable}} + {{/required}} + {{^required}} + if let Some(param_value) = {{{vendorExtensions.x-rust-param-identifier}}} { + req_builder = req_builder.header("{{{baseName}}}", param_value{{#isArray}}.join(","){{/isArray}}.to_string()); + } + {{/required}} + {{/headerParams}} + {{/hasHeaderParams}} + {{#hasAuthMethods}} + {{#authMethods}} + {{#supportTokenSource}} + // Obtain a token from source provider. + // Tokens can be Id or access tokens depending on the provider type and configuration. + let token = configuration.token_source.token().await.map_err(Error::TokenSource)?; + // The token format is the responsibility of the provider, thus we just set the authorization header with whatever is given. + req_builder = req_builder.header(reqwest::header::AUTHORIZATION, token); + {{/supportTokenSource}} + {{^supportTokenSource}} + {{#isApiKey}} + {{#isKeyInHeader}} + if let Some(ref apikey) = configuration.api_key { + let key = apikey.key.clone(); + let value = match apikey.prefix { + Some(ref prefix) => format!("{} {}", prefix, key), + None => key, + }; + req_builder = req_builder.header("{{{keyParamName}}}", value); + }; + {{/isKeyInHeader}} + {{/isApiKey}} + {{#isBasic}} + {{#isBasicBasic}} + if let Some(ref auth_conf) = configuration.basic_auth { + req_builder = req_builder.basic_auth(auth_conf.0.to_owned(), auth_conf.1.to_owned()); + }; + {{/isBasicBasic}} + {{#isBasicBearer}} + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + {{/isBasicBearer}} + {{/isBasic}} + {{#isOAuth}} + if let Some(ref token) = configuration.oauth_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + {{/isOAuth}} + {{/supportTokenSource}} + {{/authMethods}} + {{/hasAuthMethods}} + {{#isMultipart}} + {{#hasFormParams}} + let mut multipart_form = reqwest{{^supportAsync}}::blocking{{/supportAsync}}::multipart::Form::new(); + {{#formParams}} + {{#isFile}} + {{^supportAsync}} + {{#required}} + {{^isNullable}} + multipart_form = multipart_form.file("{{{baseName}}}", {{{vendorExtensions.x-rust-param-identifier}}})?; + {{/isNullable}} + {{#isNullable}} + match {{{vendorExtensions.x-rust-param-identifier}}} { + Some(param_value) => { multipart_form = multipart_form.file("{{{baseName}}}", param_value)?; }, + None => { unimplemented!("Required nullable form file param not supported"); }, + } + {{/isNullable}} + {{/required}} + {{^required}} + if let Some(param_value) = {{{vendorExtensions.x-rust-param-identifier}}} { + multipart_form = multipart_form.file("{{{baseName}}}", param_value)?; + } + {{/required}} + {{/supportAsync}} + {{#supportAsync}} + // TODO: support file upload for '{{{baseName}}}' parameter + {{/supportAsync}} + {{/isFile}} + {{^isFile}} + {{#required}} + {{^isNullable}} + multipart_form = multipart_form.text("{{{baseName}}}", {{{vendorExtensions.x-rust-param-identifier}}}{{#isArray}}.into_iter().map(|p| p.to_string()).collect::>().join(","){{/isArray}}.to_string()); + {{/isNullable}} + {{#isNullable}} + match {{{vendorExtensions.x-rust-param-identifier}}} { + Some(param_value) => { multipart_form = multipart_form.text("{{{baseName}}}", param_value{{#isArray}}.into_iter().map(|p| p.to_string()).collect::>().join(","){{/isArray}}.to_string()); }, + None => { multipart_form = multipart_form.text("{{{baseName}}}", ""); }, + } + {{/isNullable}} + {{/required}} + {{^required}} + if let Some(param_value) = {{{vendorExtensions.x-rust-param-identifier}}} { + multipart_form = multipart_form.text("{{{baseName}}}", param_value{{#isArray}}.into_iter().map(|p| p.to_string()).collect::>().join(","){{/isArray}}.to_string()); + } + {{/required}} + {{/isFile}} + {{/formParams}} + req_builder = req_builder.multipart(multipart_form); + {{/hasFormParams}} + {{/isMultipart}} + {{^isMultipart}} + {{#hasFormParams}} + let mut multipart_form_params = std::collections::HashMap::new(); + {{#formParams}} + {{#isFile}} + {{#required}} + {{^isNullable}} + multipart_form_params.insert("{{{baseName}}}", unimplemented!("File form param not supported with x-www-form-urlencoded content")); + {{/isNullable}} + {{#isNullable}} + match {{{vendorExtensions.x-rust-param-identifier}}} { + Some(param_value) => { multipart_form_params.insert("{{{baseName}}}", unimplemented!("File form param not supported with x-www-form-urlencoded content")); }, + None => { unimplemented!("Required nullable file form param not supported with x-www-form-urlencoded content"); }, + } + {{/isNullable}} + {{/required}} + {{^required}} + if let Some(param_value) = {{{vendorExtensions.x-rust-param-identifier}}} { + multipart_form_params.insert("{{{baseName}}}", unimplemented!("File form param not supported with x-www-form-urlencoded content")); + } + {{/required}} + {{/isFile}} + {{^isFile}} + {{#required}} + {{^isNullable}} + multipart_form_params.insert("{{{baseName}}}", {{{vendorExtensions.x-rust-param-identifier}}}{{#isArray}}.into_iter().map(|p| p.to_string()).collect::>().join(","){{/isArray}}.to_string()); + {{/isNullable}} + {{#isNullable}} + match {{{vendorExtensions.x-rust-param-identifier}}} { + Some(param_value) => { multipart_form_params.insert("{{{baseName}}}", param_value{{#isArray}}.into_iter().map(|p| p.to_string()).collect::>().join(","){{/isArray}}.to_string()); }, + None => { multipart_form_params.insert("{{{baseName}}}", ""); }, + } + {{/isNullable}} + {{/required}} + {{^required}} + if let Some(param_value) = {{{vendorExtensions.x-rust-param-identifier}}} { + multipart_form_params.insert("{{{baseName}}}", param_value{{#isArray}}.into_iter().map(|p| p.to_string()).collect::>().join(","){{/isArray}}.to_string()); + } + {{/required}} + {{/isFile}} + {{/formParams}} + req_builder = req_builder.form(&multipart_form_params); + {{/hasFormParams}} + {{/isMultipart}} + {{#hasBodyParam}} + {{#bodyParams}} + {{#isFile}} + req_builder = req_builder.header(reqwest::header::CONTENT_TYPE, "application/octet-stream").body({{{vendorExtensions.x-rust-param-identifier}}}); + {{/isFile}} + {{^isFile}} + req_builder = req_builder.json(&{{{vendorExtensions.x-rust-param-identifier}}}); + {{/isFile}} + {{/bodyParams}} + {{/hasBodyParam}} + + let req = req_builder.build()?; + let resp = configuration.client.execute(req){{#supportAsync}}.await{{/supportAsync}}?; + + let status = resp.status(); + {{^supportMultipleResponses}} + {{^isResponseFile}} + {{#returnType}} + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream"); + let content_type = super::ContentType::from(content_type); + {{/returnType}} + {{/isResponseFile}} + {{/supportMultipleResponses}} + + if !status.is_client_error() && !status.is_server_error() { + {{^supportMultipleResponses}} + {{#isResponseFile}} + Ok(resp) + {{/isResponseFile}} + {{^isResponseFile}} + {{^returnType}} + Ok(()) + {{/returnType}} + {{#returnType}} + let content = resp.text(){{#supportAsync}}.await{{/supportAsync}}?; + match content_type { + ContentType::Json => serde_json::from_str(&content).map_err(Error::from), + {{#vendorExtensions.x-supports-plain-text}} + ContentType::Text => return Ok(content), + {{/vendorExtensions.x-supports-plain-text}} + {{^vendorExtensions.x-supports-plain-text}} + ContentType::Text => return Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `{{returnType}}`"))), + {{/vendorExtensions.x-supports-plain-text}} + ContentType::Unsupported(unknown_type) => return Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `{{returnType}}`")))), + } + {{/returnType}} + {{/isResponseFile}} + {{/supportMultipleResponses}} + {{#supportMultipleResponses}} + {{#isResponseFile}} + Ok(resp) + {{/isResponseFile}} + {{^isResponseFile}} + let content = resp.text(){{#supportAsync}}.await{{/supportAsync}}?; + let entity: Option<{{{operationIdCamelCase}}}Success> = serde_json::from_str(&content).ok(); + Ok(ResponseContent { status, content, entity }) + {{/isResponseFile}} + {{/supportMultipleResponses}} + } else { + let content = resp.text(){{#supportAsync}}.await{{/supportAsync}}?; + let entity: Option<{{{operationIdCamelCase}}}Error> = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { status, content, entity })) + } +} + +{{/operation}} +{{/operations}} diff --git a/scripts/openapi/tests/go/go.mod b/scripts/openapi/tests/go/go.mod new file mode 100644 index 0000000000..a8283f2d35 --- /dev/null +++ b/scripts/openapi/tests/go/go.mod @@ -0,0 +1,7 @@ +module rivet-signing-wire-test + +go 1.20 + +require sdk v0.0.0 + +replace sdk => ../../../../engine/sdks/go/api-full diff --git a/scripts/openapi/tests/go/signing_test.go b/scripts/openapi/tests/go/signing_test.go new file mode 100644 index 0000000000..0013d55741 --- /dev/null +++ b/scripts/openapi/tests/go/signing_test.go @@ -0,0 +1,40 @@ +package signing_test + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "sdk/authwebhooksigning" + "sdk/option" + "testing" +) + +func TestGeneratedSigningClientPreservesBytes(t *testing.T) { + for _, body := range [][]byte{{}, {0}, {255, 254, 128}, []byte("\r\n{ \"a\": 1 }\r\n")} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + actual, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + if !bytes.Equal(actual, body) { + t.Error("body bytes changed") + } + if r.URL.Path != "/auth/webhooks/sign" || r.Header.Get("Content-Type") != "application/octet-stream" || r.Header.Get("Authorization") != "Bearer sdk-test" { + t.Error("unexpected request headers/path") + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"timestamp":"123","kid":"ds_test","signature":"test"}`) + })) + client := authwebhooksigning.NewClient(option.WithBaseURL(server.URL), option.WithToken("sdk-test")) + response, err := client.AuthWebhooksSign(context.Background(), body) + if err != nil { + t.Fatal(err) + } + if response.Timestamp != "123" { + t.Error("bad response") + } + server.Close() + } +} diff --git a/scripts/openapi/tests/signing-bytes.ts b/scripts/openapi/tests/signing-bytes.ts new file mode 100644 index 0000000000..13ba74c30b --- /dev/null +++ b/scripts/openapi/tests/signing-bytes.ts @@ -0,0 +1,24 @@ +// Bundle with esbuild for Node, then run the result. This exercises real HTTP transport. +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { RivetClient } from "../../../engine/sdks/typescript/api-full/src/index"; +for (const body of [Buffer.alloc(0), Buffer.from([0]), Buffer.from([255,254,128]), Buffer.from('\r\n{ "a": 1 }\r\n')]) { + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + assert.deepEqual(Buffer.concat(chunks), body); + assert.equal(request.url, "/auth/webhooks/sign"); + assert.equal(request.headers["content-type"], "application/octet-stream"); + assert.equal(request.headers.authorization, "Bearer sdk-test"); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({timestamp:"123", kid:"ds_test", signature:"test"})); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); assert.ok(address && typeof address !== "string"); + const client = new RivetClient({environment:`http://127.0.0.1:${address.port}`, token:"sdk-test"}); + const response = await client.authWebhookSigning.authWebhooksSign(new Blob([body]), {maxRetries:0}); + assert.equal(response.timestamp, "123"); + } finally { await new Promise(resolve => server.close(() => resolve())); } +} +console.log("Generated TypeScript client preserves all four byte fixtures");