diff --git a/contrib/auth/authentik/compose/docker-compose.yml b/contrib/auth/authentik/compose/docker-compose.yml index 612fa79faa..3e544e433c 100644 --- a/contrib/auth/authentik/compose/docker-compose.yml +++ b/contrib/auth/authentik/compose/docker-compose.yml @@ -22,6 +22,7 @@ services: NMP_CONFIG_FILE_PATH: /etc/nmp/config.yaml NMP_CONFIG_WARNINGS_DISABLED: "1" NMP_AUTH_POLICY_DECISION_POINT_BASE_URL: http://127.0.0.1:8080 + NMP_AUTH_TOKEN_SIGNING__PRIVATE_KEY_FILE: /var/run/secrets/nemo-platform/workload-token-signing/private-key.pem NMP_SEED_ON_STARTUP: "true" NMP_PLATFORM_SEED_MODEL_PROVIDER_ENABLED: "false" NMP_SECRETS_ALLOW_KEY_CREATION: "1" diff --git a/contrib/auth/authentik/compose/implementation-details.md b/contrib/auth/authentik/compose/implementation-details.md index a7c548167e..f0c7ebe0a1 100644 --- a/contrib/auth/authentik/compose/implementation-details.md +++ b/contrib/auth/authentik/compose/implementation-details.md @@ -56,9 +56,10 @@ share local keys: The workload-token private key is mounted into `nemo` at `/var/run/secrets/nemo-platform/workload-token-signing/private-key.pem`. `platform-compose-authentik.yaml` points -`auth.oidc.workload_token_private_key_file` at that mounted path. The NeMo auth -service uses the private key to sign workload-exchange access tokens, and Envoy -validates those exchanged tokens through the NeMo auth service JWKS endpoint. +`auth.token_signing.private_key_file` at that mounted path. The NeMo auth +service uses the private key to sign workload-exchange access tokens and Scoped +Access Key JWTs, and Envoy validates those tokens through the NeMo auth service +JWKS endpoints. The gateway TLS files are copied into the `gateway-tls` named volume by `gateway-tls-init`. The `gateway` service uses that volume to serve HTTPS, and @@ -117,15 +118,22 @@ Envoy is the public entrypoint for the Compose example. It routes: NeMo and Authentik through their upstream clusters. - Authentik paths to `authentik-server`. -Before JWT validation, Envoy removes incoming `X-NMP-Principal-*` headers so a -client cannot spoof identity headers. For `/apis/` requests, Envoy accepts -either: - -- Authentik-issued tokens from the demo providers. -- NeMo-issued workload-exchange tokens from `/apis/auth/token`. - -Envoy copies the validated `sub` and `groups` claims into NeMo's principal -headers. NeMo then applies its normal workspace authorization checks. +Before authentication, Envoy removes incoming `X-NMP-Principal-*` and +`X-NMP-Scopes` headers so a client cannot spoof identity or scopes. For +protected `/apis/` requests, Envoy calls NeMo's +`/apis/auth/authenticate` endpoint with the presented bearer token. The auth +service validates Authentik OIDC tokens, NeMo workload-exchange access tokens, +and NeMo Scoped Access Keys, then returns trusted `X-NMP-Principal-*` and +`X-NMP-Scopes` headers for Envoy to forward upstream. + +The gateway callout is required for dynamic or revocable Scoped Access Keys +because Envoy JWKS validation can only prove token signature, issuer, audience, +and time claims. It cannot check NeMo's access-key lifecycle state. Compose +keeps `auth.access_keys.enabled=true` so Scoped Access Keys can be created and +validated; Envoy performs the bearer-to-header mapping before the request +reaches service middleware. +Scoped Access Keys are enabled in the checked-in Compose config because the Compose +test runtime advertises the `platform_access_keys` capability. ## Workload Token Exchange diff --git a/contrib/auth/authentik/config/platform-compose-authentik.yaml b/contrib/auth/authentik/config/platform-compose-authentik.yaml index fcc3daaeca..03d84acc59 100644 --- a/contrib/auth/authentik/config/platform-compose-authentik.yaml +++ b/contrib/auth/authentik/config/platform-compose-authentik.yaml @@ -12,6 +12,12 @@ auth: policy_data_refresh_interval: 2 bundle_cache_seconds: 15 admin_email: "admin@example.com" + token_signing: + issuer: "https://nemo-gateway:8080/apis/auth" + key_id: "nemo-platform-signing" + private_key_file: "/var/run/secrets/nemo-platform/workload-token-signing/private-key.pem" + access_keys: + enabled: true oidc: enabled: true issuer: "http://authentik-server:9000/application/o/nemo-cli/" @@ -26,9 +32,7 @@ auth: workload_client_id: "nemo-platform-workload" workload_audience: "nemo-platform" workload_scope: "openid email groups" - workload_token_issuer: "https://nemo-gateway:8080/apis/auth" workload_token_endpoint: "https://nemo-gateway:8080/apis/auth/token" - workload_token_private_key_file: "/var/run/secrets/nemo-platform/workload-token-signing/private-key.pem" workload_subject_jwks_uri: "http://authentik-server:9000/application/o/nemo-workload/jwks/" workload_subject_issuers: - "http://authentik-server:9000/application/o/nemo-workload/" diff --git a/contrib/auth/authentik/gateway/envoy.yaml b/contrib/auth/authentik/gateway/envoy.yaml index 28e825a7b9..861ad81668 100644 --- a/contrib/auth/authentik/gateway/envoy.yaml +++ b/contrib/auth/authentik/gateway/envoy.yaml @@ -31,6 +31,62 @@ static_resources: prefix: "/.well-known/nemo-platform/" route: cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + request_headers_to_add: + - header: + key: x-forwarded-proto + value: https + append_action: OVERWRITE_IF_EXISTS_OR_ADD + - match: + path: "/apis/auth/discovery" + route: + cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + request_headers_to_add: + - header: + key: x-forwarded-proto + value: https + append_action: OVERWRITE_IF_EXISTS_OR_ADD + - match: + path: "/apis/auth/authenticate" + route: + cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + request_headers_to_add: + - header: + key: x-forwarded-proto + value: https + append_action: OVERWRITE_IF_EXISTS_OR_ADD + - match: + path: "/apis/auth/jwks" + route: + cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + request_headers_to_add: + - header: + key: x-forwarded-proto + value: https + append_action: OVERWRITE_IF_EXISTS_OR_ADD + - match: + path: "/apis/auth/token" + route: + cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true request_headers_to_add: - header: key: x-forwarded-proto @@ -51,10 +107,18 @@ static_resources: status: 503 body: inline_string: '{"status":"not_ready"}' + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true - match: prefix: "/health/" route: cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true request_headers_to_add: - header: key: x-forwarded-proto @@ -64,6 +128,10 @@ static_resources: path: "/status" route: cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true request_headers_to_add: - header: key: x-forwarded-proto @@ -73,6 +141,10 @@ static_resources: prefix: "/studio/" route: cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true request_headers_to_add: - header: key: x-forwarded-proto @@ -82,6 +154,10 @@ static_resources: prefix: "/" route: cluster: authentik + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true request_headers_to_add: - header: key: x-forwarded-proto @@ -125,6 +201,8 @@ static_resources: headers:remove("x-nmp-principal-on-behalf-of") headers:remove("x-nmp-principal-on-behalf-of-email") headers:remove("x-nmp-principal-on-behalf-of-groups") + headers:remove("x-nmp-authorized") + headers:remove("x-nmp-scopes") if headers:get(":path") ~= "/health/gateway/ready" then return @@ -142,58 +220,30 @@ static_resources: string.format('{"status":"not_ready","nemo":"%s","authentik":"%s"}', nemo_status, authentik_status) ) end - - name: envoy.filters.http.jwt_authn + - name: envoy.filters.http.ext_authz typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication - providers: - authentik_workload: - audiences: - - "nemo-platform" - - "nemo-platform-cli" - - "nemo-platform-workload" - remote_jwks: - http_uri: - uri: "http://authentik-server:9000/application/o/nemo/jwks/" - cluster: authentik - timeout: 5s - cache_duration: 600s - claim_to_headers: - - header_name: "X-NMP-Principal-Id" - claim_name: "sub" - - header_name: "X-NMP-Principal-Groups" - claim_name: "groups" - workload_exchange: - audiences: - - "nemo-platform" - remote_jwks: - http_uri: - uri: "http://nemo:8080/apis/auth/jwks" - cluster: nemo - timeout: 5s - cache_duration: 600s - claim_to_headers: - - header_name: "X-NMP-Principal-Id" - claim_name: "sub" - - header_name: "X-NMP-Principal-Groups" - claim_name: "groups" - rules: - - match: - path: "/apis/auth/discovery" - - match: - path: "/apis/auth/jwks" - - match: - path: "/apis/auth/token" - - match: - prefix: "/health/" - - match: - path: "/status" - - match: - prefix: "/apis/" - requires: - requires_any: - requirements: - - provider_name: "authentik_workload" - - provider_name: "workload_exchange" + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + transport_api_version: V3 + failure_mode_allow: false + status_on_error: + code: ServiceUnavailable + http_service: + server_uri: + uri: "http://nemo:8080" + cluster: nemo + timeout: 5s + path_prefix: "/apis/auth/authenticate" + authorization_response: + allowed_upstream_headers: + patterns: + - exact: x-nmp-principal-id + - exact: x-nmp-principal-email + - exact: x-nmp-principal-groups + - exact: x-nmp-scopes + allowed_client_headers: + patterns: + - exact: content-type + - exact: www-authenticate - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router diff --git a/contrib/auth/authentik/helm/templates/_envoy-config.tpl b/contrib/auth/authentik/helm/templates/_envoy-config.tpl index 8944cb9d3f..f2987b488e 100644 --- a/contrib/auth/authentik/helm/templates/_envoy-config.tpl +++ b/contrib/auth/authentik/helm/templates/_envoy-config.tpl @@ -1,6 +1,5 @@ {{- define "nemo-platform-authentik.envoyConfig" -}} {{- $authentik := required "nemo-platform.authentikEnvoy is required" .Values.authentikEnvoy -}} -{{- $oidc := .Values.platformConfig.auth.oidc -}} {{- $tlsMountPath := "" -}} {{- range .Values.envoyProxy.extraVolumeMounts -}} {{- if eq (index . "name") "workload-token-tls" -}} @@ -9,7 +8,6 @@ {{- end -}} {{- $tlsMountPath = required "nemo-platform.envoyProxy.extraVolumeMounts must include workload-token-tls" $tlsMountPath -}} {{- $apiServiceName := include "nmp-api.api-servicename" . -}} -{{- $envoyServiceName := include "nmp-envoy.servicename" . -}} {{- $spoofHeaders := concat .Values.envoyProxy.trustedHeaders (list "x-nmp-authorized" "x-nmp-scopes") | uniq -}} admin: address: @@ -49,6 +47,62 @@ static_resources: prefix: "/.well-known/nemo-platform/" route: cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + request_headers_to_add: + - header: + key: x-forwarded-proto + value: https + append_action: OVERWRITE_IF_EXISTS_OR_ADD + - match: + path: "/apis/auth/discovery" + route: + cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + request_headers_to_add: + - header: + key: x-forwarded-proto + value: https + append_action: OVERWRITE_IF_EXISTS_OR_ADD + - match: + path: "/apis/auth/authenticate" + route: + cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + request_headers_to_add: + - header: + key: x-forwarded-proto + value: https + append_action: OVERWRITE_IF_EXISTS_OR_ADD + - match: + path: "/apis/auth/jwks" + route: + cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + request_headers_to_add: + - header: + key: x-forwarded-proto + value: https + append_action: OVERWRITE_IF_EXISTS_OR_ADD + - match: + path: "/apis/auth/token" + route: + cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true request_headers_to_add: - header: key: x-forwarded-proto @@ -69,10 +123,18 @@ static_resources: status: 503 body: inline_string: '{"status":"not_ready"}' + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true - match: prefix: "/health/" route: cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true request_headers_to_add: - header: key: x-forwarded-proto @@ -82,6 +144,10 @@ static_resources: path: "/status" route: cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true request_headers_to_add: - header: key: x-forwarded-proto @@ -91,6 +157,10 @@ static_resources: prefix: "/studio/" route: cluster: nemo + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true request_headers_to_add: - header: key: x-forwarded-proto @@ -100,6 +170,10 @@ static_resources: prefix: "/" route: cluster: authentik + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true http_filters: - name: envoy.filters.http.lua typed_config: @@ -152,56 +226,30 @@ static_resources: string.format('{"status":"not_ready","nemo":"%s","authentik":"%s"}', nemo_status, authentik_status) ) end - - name: envoy.filters.http.jwt_authn + - name: envoy.filters.http.ext_authz typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication - providers: - authentik_workload: - audiences: - - {{ $oidc.workload_audience | quote }} - - {{ $oidc.client_id | quote }} - - {{ $oidc.workload_client_id | quote }} - remote_jwks: - http_uri: - uri: {{ printf "https://%s:%v/application/o/nemo/jwks/" $envoyServiceName .Values.envoyProxy.service.port | quote }} - cluster: nemo_envoy_https - timeout: 5s - cache_duration: 600s - claim_to_headers: - - header_name: "X-NMP-Principal-Id" - claim_name: "sub" - - header_name: "X-NMP-Principal-Groups" - claim_name: "groups" - workload_exchange: - audiences: - - {{ $oidc.workload_audience | quote }} - remote_jwks: - http_uri: - uri: {{ printf "https://%s:%v/apis/auth/jwks" $envoyServiceName .Values.envoyProxy.service.port | quote }} - cluster: nemo_envoy_https - timeout: 5s - cache_duration: 600s - claim_to_headers: - - header_name: "X-NMP-Principal-Id" - claim_name: "sub" - - header_name: "X-NMP-Principal-Groups" - claim_name: "groups" - rules: - - match: - path: "/apis/auth/discovery" - - match: - path: "/apis/auth/jwks" - - match: - path: "/apis/auth/token" - - match: - prefix: "/health/" - - match: - prefix: "/apis/" - requires: - requires_any: - requirements: - - provider_name: "authentik_workload" - - provider_name: "workload_exchange" + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + transport_api_version: V3 + failure_mode_allow: false + status_on_error: + code: ServiceUnavailable + http_service: + server_uri: + uri: {{ printf "http://%s:%v" $apiServiceName .Values.api.service.port | quote }} + cluster: nemo + timeout: 5s + path_prefix: "/apis/auth/authenticate" + authorization_response: + allowed_upstream_headers: + patterns: + - exact: x-nmp-principal-id + - exact: x-nmp-principal-email + - exact: x-nmp-principal-groups + - exact: x-nmp-scopes + allowed_client_headers: + patterns: + - exact: content-type + - exact: www-authenticate - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router @@ -230,25 +278,4 @@ static_resources: socket_address: address: {{ $authentik.serviceName }} port_value: {{ $authentik.servicePort }} - - name: nemo_envoy_https - connect_timeout: 5s - type: LOGICAL_DNS - transport_socket: - name: envoy.transport_sockets.tls - typed_config: - "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext - sni: {{ $envoyServiceName }} - common_tls_context: - validation_context: - trusted_ca: - filename: {{ printf "%s/ca.crt" $tlsMountPath | quote }} - load_assignment: - cluster_name: nemo_envoy_https - endpoints: - - lb_endpoints: - - endpoint: - address: - socket_address: - address: {{ $envoyServiceName }} - port_value: {{ .Values.envoyProxy.service.port }} {{- end -}} diff --git a/contrib/auth/authentik/helm/values.yaml b/contrib/auth/authentik/helm/values.yaml index 4de9af9ac7..578ecff364 100644 --- a/contrib/auth/authentik/helm/values.yaml +++ b/contrib/auth/authentik/helm/values.yaml @@ -111,6 +111,12 @@ nemo-platform: volcanoEnabled: false postgresql: enabled: false + clickhouse: + enabled: false + externalClickhouse: + host: unused-clickhouse + existingSecret: shared-postgresql + existingSecretPasswordKey: nemo-password externalDatabase: host: shared-postgresql port: 5432 @@ -130,6 +136,10 @@ nemo-platform: serviceAccount: create: true name: nemo + extraArgs: + - "--service-group=core" + env: + NMP_AUTH_TOKEN_SIGNING__PRIVATE_KEY_FILE: "/etc/nmp/workload-token/private-key.pem" extraVolumes: - name: workload-token-signing-key secret: @@ -188,6 +198,12 @@ nemo-platform: policy_data_refresh_interval: 2 bundle_cache_seconds: 15 admin_email: "admin@example.com" + token_signing: + issuer: '{{ include "nemo-platform-authentik.serviceUrl" (dict "root" . "serviceName" "nemo-platform-envoy" "namespace" .Values.envoyProxy.serviceNamespace "scheme" "https" "port" 8080) }}/apis/auth' + key_id: "nemo-platform-signing" + private_key_file: "/etc/nmp/workload-token/private-key.pem" + access_keys: + enabled: false oidc: enabled: true issuer: '{{ include "nemo-platform-authentik.serviceUrl" (dict "root" . "serviceName" "authentik-server" "scheme" "http") }}/application/o/nemo-cli/' @@ -202,10 +218,12 @@ nemo-platform: workload_client_id: "nemo-platform-workload" workload_audience: "nemo-platform" workload_scope: "openid email groups" - workload_token_issuer: '{{ include "nemo-platform-authentik.serviceUrl" (dict "root" . "serviceName" "nemo-platform-envoy" "namespace" .Values.envoyProxy.serviceNamespace "scheme" "https" "port" 8080) }}/apis/auth' workload_token_endpoint: '{{ include "nemo-platform-authentik.serviceUrl" (dict "root" . "serviceName" "nemo-platform-envoy" "namespace" .Values.envoyProxy.serviceNamespace "scheme" "https" "port" 8080) }}/apis/auth/token' - workload_token_key_id: "nemo-workload-exchange" - workload_token_private_key_file: "/etc/nmp/workload-token/private-key.pem" + workload_subject_jwks_uri: '{{ include "nemo-platform-authentik.serviceUrl" (dict "root" . "serviceName" "authentik-server" "scheme" "http") }}/application/o/nemo-workload/jwks/' + workload_subject_issuers: + - '{{ include "nemo-platform-authentik.serviceUrl" (dict "root" . "serviceName" "authentik-server" "scheme" "http") }}/application/o/nemo-workload/' + - '{{ include "nemo-platform-authentik.serviceUrl" (dict "root" . "serviceName" "nemo-platform-envoy" "namespace" .Values.envoyProxy.serviceNamespace "scheme" "https" "port" 8080) }}/application/o/nemo-workload/' + - '{{ include "nemo-platform-authentik.publicGatewayUrl" . }}/application/o/nemo-workload/' workload_kubernetes_token_review_enabled: true subject_claim: "sub" email_claim: "email" diff --git a/contrib/auth/authentik/kubernetes/implementation-details.md b/contrib/auth/authentik/kubernetes/implementation-details.md index 0a2722caeb..017c797b7e 100644 --- a/contrib/auth/authentik/kubernetes/implementation-details.md +++ b/contrib/auth/authentik/kubernetes/implementation-details.md @@ -65,7 +65,7 @@ The umbrella chart passes the Kubernetes-specific NeMo Platform configuration through `nemo-platform.platformConfig` values. It also configures `nemo-platform.envoyProxy.configOverride` so the NeMo Platform chart's Envoy deployment keeps the Authentik path split and validates both Authentik-issued -tokens and NeMo workload-exchange tokens. +tokens, NeMo workload-exchange tokens, and NeMo Scoped Access Key JWTs. Kubernetes projected service account token expiration defaults to `600` seconds in the jobs backend. Override it through the NeMo Platform chart values if you @@ -108,16 +108,26 @@ kubectl --context "${KUBE_CONTEXT}" -n "${NAMESPACE}" describe pod \ -l "nmp.nvidia.com/job_id=${JOB_NAME}" ``` -## Workload Token Signing Key +## Shared Token Signing Key Workload identity token exchange requires the NeMo auth service to sign the access token it mints from a Kubernetes projected service account subject token. The chart creates `Secret/nemo-workload-token-signing-key` by default, mounts `private-key.pem` into the NeMo Platform API pod at `/etc/nmp/workload-token/private-key.pem`, and sets -`auth.oidc.workload_token_private_key_file` to that path. The matching public -key is served from `/apis/auth/jwks`; the NeMo Platform chart's Envoy deployment -uses that JWKS endpoint to validate exchanged workload tokens. +`auth.token_signing.private_key_file` to that path. The same shared signing +configuration is used for workload-exchange access tokens and Scoped Access Key +JWTs. The matching public keys are served from `/apis/auth/jwks`. + +The Helm-rendered Envoy config authenticates protected `/apis/` requests by +calling `/apis/auth/authenticate` on the NeMo API service. It does not use Envoy +`claim_to_headers` for Scoped Access Keys. This keeps future revocation and +dynamic-key checks inside the auth service, where access-key records can be +looked up before Envoy forwards trusted principal headers. + +Scoped Access Keys remain disabled in the checked-in chart values by default. +Runtime tests and local experiments can enable them with +`nemo-platform.platformConfig.auth.access_keys.enabled=true`. The manual walkthrough can rely on the Helm chart to create and preserve this Secret. @@ -137,4 +147,4 @@ Secret instead of relying on the demo-generated key. Set `workloadTokenSigningKey.create=false`, keep `workloadTokenSigningKey.secretName` and `nemo-platform.api.extraVolumes[].secret.secretName` aligned, and keep -`auth.oidc.workload_token_private_key_file` pointed at the mounted file path. +`auth.token_signing.private_key_file` pointed at the mounted file path. diff --git a/contrib/auth/authentik/manifest.yaml b/contrib/auth/authentik/manifest.yaml index 2ac6588a85..d108f59b57 100644 --- a/contrib/auth/authentik/manifest.yaml +++ b/contrib/auth/authentik/manifest.yaml @@ -65,6 +65,7 @@ test_runtimes: - workload_provider_token - workload_subject_token - workload_token_exchange + - platform_access_keys - workspace_rbac - workload_job - device_flow @@ -79,6 +80,7 @@ test_runtimes: - workload_provider_token - workload_subject_token - workload_token_exchange + - platform_access_keys - workspace_rbac - workload_job - device_flow diff --git a/contrib/auth/authentik/tutorial.md b/contrib/auth/authentik/tutorial.md index ad28fd67fc..6f439722ae 100644 --- a/contrib/auth/authentik/tutorial.md +++ b/contrib/auth/authentik/tutorial.md @@ -8,6 +8,7 @@ The tutorial covers: - NeMo CLI login through Authentik. - NeMo API calls through the Authentik gateway. +- NeMo Scoped Access Keys through the Authentik gateway. - Workload identity token exchange through a workload job. For shared identities, token lifetimes, and automated test harness commands, @@ -141,6 +142,7 @@ helm --kube-context "${KUBE_CONTEXT}" upgrade --install "${HELM_RELEASE}" contri --set-string nemo-platform.core.image.tag="${BAKE_TAG}" \ --set-string nemo-platform.platformConfig.platform.image_registry="${IMAGE_REGISTRY}" \ --set-string nemo-platform.platformConfig.platform.image_tag="${BAKE_TAG}" \ + --set nemo-platform.platformConfig.auth.access_keys.enabled=true \ --set-file workloadTokenSigningKey.privateKeyPem=contrib/auth/authentik/.generated/workload-token-private-key.pem ``` @@ -268,6 +270,84 @@ uv run nemo --context "$AUTHENTIK_CONTEXT" workspaces members create \ Expected result: the human user can manage the workspace, and the workload identity can read the workspace from a job and upload the job logs. +## Test Scoped Access Keys + +Create a short-lived Scoped Access Key for the logged-in user. The command +prints the key once, so store it in a shell variable and do not echo it: + +```bash +ACCESS_KEY="$(uv run nemo --context "$AUTHENTIK_CONTEXT" auth access-keys create \ + --name "authentik-reference-${AUTHENTIK_RUNTIME}" \ + --expires-in 600)" + +test -n "$ACCESS_KEY" +``` + +Authenticate through the gateway with the access key: + +```bash +curl --cacert "$AUTHENTIK_GATEWAY_CA" -sf \ + -H "Authorization: Bearer ${ACCESS_KEY}" \ + "${AUTHENTIK_BASE_URL}/apis/auth/authenticate" +``` + +Expected result: the response contains `"token_kind":"access_key"` and the +principal for the logged-in demo user. + +Use the same access key against a workspace API: + +```bash +curl --cacert "$AUTHENTIK_GATEWAY_CA" -sf \ + -H "Authorization: Bearer ${ACCESS_KEY}" \ + "${AUTHENTIK_BASE_URL}/apis/entities/v2/workspaces/${WORKSPACE}" +``` + +Expected result: the response contains `"name":"authentik-demo"`. This uses the +`nemo-editors` workspace role grant from the previous section. + +Save the access key as a separate CLI context and verify that normal CLI +commands can use it: + +```bash +ACCESS_KEY_CONTEXT="${AUTHENTIK_CONTEXT}-access-key" + +uv run nemo config set \ + --context "$ACCESS_KEY_CONTEXT" \ + --base-url "$AUTHENTIK_BASE_URL" \ + --access-token "$ACCESS_KEY" \ + --workspace "$WORKSPACE" + +uv run nemo --context "$ACCESS_KEY_CONTEXT" workspaces get "$WORKSPACE" +uv run nemo config use-context "$AUTHENTIK_CONTEXT" +``` + +Expected result: `workspaces get` returns the same workspace through the saved +access-key context. The final command restores the logged-in Authentik context +for the rest of the tutorial. + +Verify that the gateway rejects a malformed access key: + +```bash +INVALID_ACCESS_KEY="${ACCESS_KEY%?}A" +if [ "$INVALID_ACCESS_KEY" = "$ACCESS_KEY" ]; then + INVALID_ACCESS_KEY="${ACCESS_KEY%?}B" +fi + +INVALID_STATUS="$(curl --cacert "$AUTHENTIK_GATEWAY_CA" -sS -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${INVALID_ACCESS_KEY}" \ + "${AUTHENTIK_BASE_URL}/apis/auth/authenticate")" + +test "$INVALID_STATUS" = "401" +``` + +Expected result: the malformed key returns HTTP `401`. The valid key expires +after 600 seconds. When you are finished with the valid key, remove it from the +current shell: + +```bash +unset ACCESS_KEY ACCESS_KEY_CONTEXT INVALID_ACCESS_KEY INVALID_STATUS +``` + ## Run A Workload Job Submit a workload job that reads the workspace through the public SDK: diff --git a/docs/auth/authentication/using-authentication.mdx b/docs/auth/authentication/using-authentication.mdx index 98db30298a..8de2895ddf 100644 --- a/docs/auth/authentication/using-authentication.mdx +++ b/docs/auth/authentication/using-authentication.mdx @@ -1,10 +1,10 @@ --- title: "Using Authentication" -description: "" +description: "Log in, call APIs, manage tokens, and create Scoped Access Keys." --- -How to log in, make authenticated API calls, and manage tokens with the CLI and SDK. +How to log in, make authenticated API calls, manage tokens, and create Scoped Access Keys with the CLI and SDK. -**Prerequisites**: OIDC must be configured on the platform. See [OIDC Setup](/documentation/access-control/authentication/oidc-setup). +**Prerequisites**: For OIDC login, configure an identity provider first. See [OIDC](/documentation/access-control/authentication/oidc). For local testing without OIDC, use `nemo auth login --unsigned-token --email `. Scoped Access Keys also require the administrator to enable `auth.access_keys.enabled`; see [Authentication Configuration](/documentation/access-control/deployment/configuration#scoped-access-keys). ## Log In @@ -29,9 +29,17 @@ nemo auth status ``` ```text -Logged in as alice@company.com -Scopes: platform:read platform:write -Token expires: 2026-02-15T14:30:00Z +Authentication Status +Cluster https://nmp.company.com +Context default +Config File /home/alice/.config/nmp/config.yaml +Auth Type oauth +Credential Source config file +Email alice@company.com +Scopes platform:read platform:write +Expires 2026-02-15T14:30:00+00:00 (1h 0m remaining) +Refresh Token available (run 'nemo auth refresh' to renew) +Token eyJhbGciOiJSUzI1NiIs...abc1234567 ``` All CLI and SDK commands now use the stored token automatically. @@ -64,7 +72,7 @@ The SDK reads credentials from the CLI config automatically — no manual token ```python from nemo_platform import NeMoPlatform -# After `nemo auth login` (OIDC) or `nemo auth login --unsigned-token ...` (quickstart), +# After `nemo auth login` (OIDC) or `nemo auth login --unsigned-token --email ` (quickstart), # the SDK reads base_url, workspace, and the stored token from the CLI config. # This is the recommended pattern for interactive / OIDC-authenticated usage. client = NeMoPlatform() @@ -90,9 +98,39 @@ client = NeMoPlatform( ```bash TOKEN=$(nemo auth token) curl -H "Authorization: Bearer $TOKEN" \ - https://nmp.company.com/v2/workspaces + https://nmp.company.com/apis/entities/v2/workspaces ``` +## Scoped Access Keys for Non-SDK Clients + +When Scoped Access Keys are enabled by the platform administrator, an +authenticated user can mint a scoped bearer token for automation that cannot use +the SDK's OIDC refresh flow: + +```bash +# Create a Scoped Access Key with the platform default expiry and print the token once. +nemo auth access-keys create --name ci-build +``` + +Scoped Access Key management commands live under the `auth` namespace as +`nemo auth access-keys ...`. The `access-keys` command group is not a top-level CLI +command and is not a separate NeMo Platform plugin. + +`create` prints the Scoped Access Key token once. Store it in your secret +manager and send it in the standard `Authorization` header: + +```bash +curl -H "Authorization: Bearer $NMP_SCOPED_ACCESS_KEY" \ + https://nmp.company.com/apis/entities/v2/workspaces +``` + +Scoped Access Keys are signed JWT bearer tokens scoped to the principal and +groups present when the key is created. By default, new keys use the platform's +configured default expiry, which is 30 days unless the administrator changes it. +Pass `--expires-in ` to request a specific finite lifetime. Pass +`--expires-in none` only for deployments where the administrator has explicitly +allowed unlimited keys. Revocation and rotation are not implemented. + ### Token Inspection Retrieve the raw JWT for debugging or use in other clients: @@ -104,7 +142,7 @@ nemo auth token Decode the token to inspect claims: ```bash -nemo auth token | cut -d. -f2 | base64 -d 2>/dev/null | python -m json.tool +nemo auth token --decode ``` Key claims to check: @@ -145,10 +183,10 @@ Tokens are stored in `~/.config/nmp/config.yaml`: ```yaml users: - - type: oauth - name: default - token: "" - refresh_token: "" + - name: default + type: oauth + token: "" + refresh_token: "" ``` The OIDC token endpoint is **not** stored — it is discovered at runtime from your cluster's `/apis/auth/discovery` endpoint. This keeps the config portable across environments. @@ -165,7 +203,7 @@ The OIDC token endpoint is **not** stored — it is discovered at runtime from y ## Related -- [OIDC Setup](/documentation/access-control/authentication/oidc-setup) — Configure your identity provider. +- [OIDC](/documentation/access-control/authentication/oidc) — Configure your identity provider. - [API Scopes](/documentation/access-control/authorization/api-scopes) — Scope model and available scopes. - [Security Model](/documentation/access-control/security-model) — Trust boundaries and the principal model. - [Troubleshooting](/documentation/access-control/troubleshooting) — Fix common 401/403 errors and login failures. diff --git a/docs/auth/authorization/policy-engine.mdx b/docs/auth/authorization/policy-engine.mdx index d133183a6f..f88f57af3c 100644 --- a/docs/auth/authorization/policy-engine.mdx +++ b/docs/auth/authorization/policy-engine.mdx @@ -87,7 +87,6 @@ auth: Use external OPA when you: - Already run OPA for other services and want a single policy engine -- Need gateway-level auth via Envoy `ext_authz` with gRPC - Want to add custom policy rules alongside NeMo Platform authorization - Prefer to manage OPA's lifecycle separately diff --git a/docs/auth/deployment/configuration.mdx b/docs/auth/deployment/configuration.mdx index d8e7dd5a73..8512d524bc 100644 --- a/docs/auth/deployment/configuration.mdx +++ b/docs/auth/deployment/configuration.mdx @@ -22,7 +22,7 @@ When using Helm, this is done by setting `platformConfig.auth.enabled: true` in platformConfig: auth: - enabled: true + enabled: true ``` When `auth.enabled` is `false` (the default), all API requests are allowed without checks. When `true`, every request is evaluated by the Policy Decision Point (PDP). In Helm deployments, this setting is controlled via `platformConfig.auth.enabled`. @@ -78,26 +78,119 @@ NMP_AUTH_POLICY_DECISION_POINT_BASE_URL=http://auth:8000 NMP_AUTH_POLICY_DECISION_POINT_PROVIDER=embedded NMP_AUTH_ADMIN_EMAIL=admin@example.com NMP_AUTH_EMBEDDED_PDP_AUTO_BUILD_WASM=true +NMP_AUTH_TOKEN_SIGNING__PRIVATE_KEY_FILE=/etc/nmp/workload-token/private-key.pem +``` + +Nested auth keys use a double underscore after `NMP_AUTH_`: for example, +`NMP_AUTH_OIDC__ISSUER`, `NMP_AUTH_OIDC__CLIENT_ID`, and +`NMP_AUTH_ACCESS_KEYS__ENABLED`. + +## Scoped Access Keys + +Scoped Access Keys let an authenticated user create a scoped bearer token for +non-SDK clients and automation. The implementation creates user-scoped signed +JWT access keys and rejects service principals. Revocation and rotation are not +implemented. + +Scoped Access Keys are an auth-service feature exposed under the auth CLI +namespace (`nemo auth access-keys ...`) and the `/apis/auth/v2/access-keys` API routes. +They are not a standalone NeMo Platform plugin. + +Scoped Access Keys are disabled by default. Enable them only when the auth +service has a shared RSA signing key: + +```yaml +auth: + enabled: true + token_signing: + issuer: "https://nmp.company.com/apis/auth" + key_id: "nemo-platform-signing" + private_key_file: "/etc/nmp/workload-token/private-key.pem" + access_keys: + enabled: true + issue_format: "jwt" + accepted_formats: ["jwt"] + audience: "nemo-platform-access-key" + default_expires_in_seconds: 2592000 + max_expires_in_seconds: 2592000 ``` -Nested keys (e.g., OIDC) use double underscore: `NMP_AUTH_OIDC__ISSUER`, `NMP_AUTH_OIDC__CLIENT_ID`. +Equivalent environment overrides: + +```bash +NMP_AUTH_TOKEN_SIGNING__ISSUER=https://nmp.company.com/apis/auth +NMP_AUTH_TOKEN_SIGNING__KEY_ID=nemo-platform-signing +NMP_AUTH_TOKEN_SIGNING__PRIVATE_KEY_FILE=/etc/nmp/workload-token/private-key.pem +NMP_AUTH_ACCESS_KEYS__ENABLED=true +NMP_AUTH_ACCESS_KEYS__ISSUE_FORMAT=jwt +NMP_AUTH_ACCESS_KEYS__ACCEPTED_FORMATS=jwt +NMP_AUTH_ACCESS_KEYS__AUDIENCE=nemo-platform-access-key +NMP_AUTH_ACCESS_KEYS__DEFAULT_EXPIRES_IN_SECONDS=2592000 +NMP_AUTH_ACCESS_KEYS__MAX_EXPIRES_IN_SECONDS=2592000 +``` + +List-valued env vars for accepted Scoped Access Key formats are written as +comma-separated values, for example `NMP_AUTH_ACCESS_KEYS__ACCEPTED_FORMATS=jwt`. + +Service-level `AuthorizationMiddleware` accepts Scoped Access Key bearer tokens +directly when `auth.access_keys.enabled=true`. When +`auth.access_keys.enabled=false`, the middleware does not try to authenticate +Scoped Access Keys. Gateway deployments that use `/apis/auth/authenticate` still +keep `auth.access_keys.enabled=true` when they want Scoped Access Keys to work, +because the same flag controls creation and auth-service validation. + +Scoped Access Keys expire after 30 days by default. Create requests may omit +`expires_in_seconds`; the auth service then uses +`auth.access_keys.default_expires_in_seconds`. Callers may request a shorter +or longer finite lifetime with `expires_in_seconds` or CLI `--expires-in`, but +the requested value must be less than or equal to +`auth.access_keys.max_expires_in_seconds` when the max is configured. + +Set `auth.access_keys.max_expires_in_seconds` to `null` only when unlimited +keys are permitted. With `max_expires_in_seconds: null` and a finite +`default_expires_in_seconds`, ordinary omitted-expiry requests still use the +default lifetime, while explicit `expires_in_seconds: null` or CLI +`--expires-in none` creates a no-expiration key. Set both max and default to +`null` only when omitted-expiry requests should also create no-expiration keys. +With a finite `max_expires_in_seconds` and `default_expires_in_seconds: null`, +callers must provide a finite `expires_in_seconds`. When +`max_expires_in_seconds` is `null`, callers may still provide a finite +`expires_in_seconds`, and the auth service honors that finite lifetime. + +The issuer defaults to `/apis/auth` when +`auth.token_signing.issuer` is unset. Use an externally reachable issuer URL +when a gateway validates Scoped Access Keys before forwarding requests to +platform services. ## OIDC Workload Identity Exchange -`auth.oidc` can also advertise SDK workload identity token exchange metadata: +`auth.oidc` can also advertise SDK workload identity token exchange metadata. +Workload tokens share `auth.token_signing` by default, while +`auth.oidc.workload_token_*` fields remain available for workload-specific +overrides: ```yaml auth: + token_signing: + issuer: "https://nmp.company.com/apis/auth" + key_id: "nemo-platform-signing" + private_key_file: "/etc/nmp/workload-token/private-key.pem" oidc: - enabled: true - token_endpoint: "https://idp.example.com/oauth/token" - workload_token_exchange_enabled: true - workload_client_id: "nemo-platform-workload" - workload_token_endpoint: "https://idp.example.com/oauth/token" - workload_audience: "nemo-platform" - workload_scope: "openid email groups" + enabled: true + token_endpoint: "https://idp.example.com/oauth/token" + workload_token_exchange_enabled: true + workload_client_id: "nemo-platform-workload" + workload_token_endpoint: "https://idp.example.com/oauth/token" + workload_audience: "nemo-platform" + workload_scope: "openid email groups" ``` +By default, workload-exchange access tokens use `auth.token_signing.key_id` as +their JWT `kid`. To give workload tokens a distinct key identifier, set +`auth.oidc.workload_token_key_id`. For cryptographic separation, also set +`auth.oidc.workload_token_private_key_file`; changing only the key ID is useful +for debugging and rotation labeling, but still reuses the same RSA private key. + When `NMP_WORKLOAD_IDENTITY_TOKEN_FILE` is present, the SDK reads the subject token from that file and sends an RFC 8693 exchange request using `subject_token`, fixed JWT subject/access-token token types, optional `audience`, @@ -197,9 +290,9 @@ auth: policy_data_refresh_interval: 30 admin_email: "platform-admin@company.com" oidc: - enabled: true - issuer: "https://login.microsoftonline.com//v2.0" - client_id: "" + enabled: true + issuer: "https://login.microsoftonline.com//v2.0" + client_id: "" ``` ### Production with external OPA diff --git a/docs/auth/deployment/gateway.mdx b/docs/auth/deployment/gateway.mdx index 70ab56e0d6..0fe3376f02 100644 --- a/docs/auth/deployment/gateway.mdx +++ b/docs/auth/deployment/gateway.mdx @@ -2,51 +2,54 @@ title: "Gateway Integration" description: "" --- -In production, a gateway (reverse proxy, ingress controller, or service mesh) often sits in front of the NeMo Platform. This page explains how authorization works with and without gateway-level auth, what headers the gateway must set, and which paths skip authorization. +In production, a gateway (reverse proxy, ingress controller, or service mesh) often sits in front of the NeMo Platform. This page explains the recommended enforced gateway path for bearer-token authentication, what headers the gateway must set, and which paths skip authorization. For the security architecture, see [Security Model](/documentation/access-control/security-model). ## Overview -NeMo Platform's authorization middleware runs **inside each service**. Every request is evaluated there unless auth is disabled or the request matches a bypass path. Optionally, the **gateway** can perform the authorization check (e.g., via Envoy `ext_authz`) and forward the request with a special header so that services **trust the gateway's decision** and do not call the PDP again. That reduces latency and centralizes auth at the edge. +NeMo Platform services trust identity headers only after a bearer token has been validated. In gateway deployments, the recommended path is: -## Two Authorization Models +1. The gateway calls `/apis/auth/authenticate` before forwarding protected API requests. +2. The auth service validates the bearer token and returns trusted `X-NMP-Principal-*` and `X-NMP-Scopes` headers. +3. The gateway forwards those trusted headers to NeMo Platform services. +4. Services run their normal PDP authorization checks using the trusted principal and scopes. -### Service-Level Auth (Default) +This callout can validate any bearer token accepted by the auth service: OIDC access tokens when `auth.oidc.enabled=true`, Scoped Access Keys when `auth.access_keys.enabled=true`, and workload tokens when workload identity is enabled. -- The gateway forwards requests unchanged (aside from routing/TLS). -- Each service's middleware validates the token (or principal headers) and calls the PDP. -- **No gateway auth configuration required.** Easiest to set up. +If gateway enforcement is not required, the gateway can forward bearer tokens unchanged and let service middleware validate them directly. This page focuses on the enforced gateway path. -### Gateway-Level Auth +## Gateway Bearer-Auth Callout -- The gateway calls the PDP (e.g., via Envoy `ext_authz`) before forwarding. -- If the PDP allows the request, the gateway adds headers and forwards; otherwise it returns 403. -- Services see `x-nmp-authorized: true` and the principal headers, and **skip** their own PDP call. -- **Benefit**: One auth check per request at the edge; lower latency and fewer PDP calls. +To use gateway bearer-auth callout, configure your gateway to call the NeMo Platform auth service with the original `Authorization` header. On success, forward only the trusted headers returned by the auth service. This keeps token validation and NeMo claim mapping inside the auth service instead of duplicating them in static gateway JWT config. -To use gateway-level auth you must configure your gateway to call the NeMo Platform PDP and set the headers described below on allowed requests. + + +For IdP-issued tokens, enforce your IdP's revocation policy before setting NeMo headers. Do not rely on a gateway rule that only maps JWT claims into headers, such as Envoy `claim_to_headers`, when immediate revocation must be honored. + + **Security Requirement**: Your ingress/gateway **must** strip the following headers from all incoming external requests before forwarding to NeMo Platform: - `X-NMP-Principal-Id`, `X-NMP-Principal-Email`, `X-NMP-Principal-Groups`, `X-NMP-Principal-On-Behalf-Of` -- `X-NMP-Authorized`, `X-NMP-Scopes` +- `X-NMP-Scopes` If external clients can set these headers, they can forge any identity or bypass authorization entirely. The gateway should also block external access to `/internal/*` paths (used for service-to-service communication). -## Required Headers (Gateway-Level Auth) -When the gateway has already authorized the request, it must set: +## Required Headers (Gateway Bearer-Auth Callout) + +When the gateway has authenticated the bearer token, it must forward: | Header | Description | |--------|-------------| -| `X-NMP-Authorized` | Must be `true` so services trust the gateway's decision and skip PDP. | | `X-NMP-Principal-Id` | Principal identifier (e.g., user ID or email). Required. | | `X-NMP-Principal-Email` | User email (optional but recommended). | | `X-NMP-Principal-Groups` | Comma-separated group names (optional). | +| `X-NMP-Scopes` | Space-separated token scopes (optional). | Header names are case-insensitive; services normalize them. @@ -59,11 +62,11 @@ The following are **not** subject to authorization checks; they are always allow - **PDP endpoints**: Paths under `/apis/auth/v2/authz/` are restricted to service principals only. The middleware rejects external and regular-user requests automatically. - **Studio**: Paths under `/studio` (the Studio UI handles its own OIDC login) -Configure the gateway so these paths are not sent to the PDP (or are always allowed) when using gateway-level auth. +Configure the gateway so these paths are not sent to `/apis/auth/authenticate` when using gateway bearer-auth callout. ## Gateway Configuration Examples -### Envoy `ext_authz` +### Envoy Auth-Service Callout @@ -71,41 +74,33 @@ Replace placeholder values before applying this configuration. ```yaml http_filters: - - name: envoy.filters.http.jwt_authn - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication - providers: - : - issuer: "" - remote_jwks: - http_uri: - uri: "" - cluster: - timeout: 5s - cache_duration: 600s - claim_to_headers: - - header_name: "X-NMP-Principal-Id" - claim_name: "sub" - - header_name: "X-NMP-Principal-Email" - claim_name: "email" - rules: - - match: - prefix: "/" - requires: - provider_name: "" - name: envoy.filters.http.ext_authz typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz - grpc_service: - envoy_grpc: - cluster_name: - transport_api_version: V3 + transport_api_version: V3 failure_mode_allow: false + status_on_error: + code: ServiceUnavailable + http_service: + server_uri: + uri: "http://nemo:8080" + cluster: nemo + timeout: 5s + path_prefix: "/apis/auth/authenticate" + authorization_response: + allowed_upstream_headers: + patterns: + - exact: x-nmp-principal-id + - exact: x-nmp-principal-email + - exact: x-nmp-principal-groups + - exact: x-nmp-scopes - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router ``` +Envoy HTTP `ext_authz` callouts preserve the original request method and build the check URL from `path_prefix` plus the original request path. For example, `DELETE /apis/entities/v2/workspaces/default` is checked as `DELETE /apis/auth/authenticate/apis/entities/v2/workspaces/default`. NeMo accepts that prefixed callout URL, validates only the bearer token, and returns trusted principal headers. The original request path is still authorized later by the service PDP check after Envoy forwards the request. + ### Header Stripping @@ -126,7 +121,6 @@ route_config: - "x-nmp-principal-groups" - "x-nmp-principal-on-behalf-of" - "x-nmp-scopes" - - "x-nmp-authorized" routes: - match: { prefix: "/" } route: { cluster: nmp_backend } @@ -135,10 +129,10 @@ route_config: ## Testing Gateway Auth -After configuring gateway-level auth, verify: +After configuring gateway bearer-auth callout, verify: -1. **Headers are set correctly** — Make a request through the gateway and check that `X-NMP-Authorized` and `X-NMP-Principal-Id` are present on the service side. -2. **Headers are stripped from external requests** — Try sending `X-NMP-Authorized: true` from outside; verify it is stripped by the gateway. +1. **Headers are set correctly** — Make a request through the gateway and check that `X-NMP-Principal-Id` and `X-NMP-Scopes` are present on the service side when the token contains scopes. +2. **Headers are stripped from external requests** — Try sending `X-NMP-Principal-Id` or `X-NMP-Scopes` from outside; verify they are stripped by the gateway. ## Related diff --git a/docs/auth/deployment/hardening.mdx b/docs/auth/deployment/hardening.mdx index 60a3a49874..33432ca88f 100644 --- a/docs/auth/deployment/hardening.mdx +++ b/docs/auth/deployment/hardening.mdx @@ -25,9 +25,9 @@ For the security architecture, see [Security Model](/documentation/access-contro ## Gateway and Network -- [ ] **Strip auth headers from external requests**: Configure your ingress/gateway to remove `X-NMP-Principal-Id`, `X-NMP-Principal-Email`, `X-NMP-Principal-Groups`, `X-NMP-Principal-On-Behalf-Of`, `X-NMP-Authorized`, and `X-NMP-Scopes` from all incoming external traffic. See [Gateway Integration](/documentation/access-control/deployment/gateway-integration). +- [ ] **Strip auth headers from external requests**: Configure your ingress/gateway to remove `X-NMP-Principal-Id`, `X-NMP-Principal-Email`, `X-NMP-Principal-Groups`, `X-NMP-Principal-On-Behalf-Of`, and `X-NMP-Scopes` from all incoming external traffic. See [Gateway Integration](/documentation/access-control/deployment/gateway-integration). - [ ] **Enable TLS termination**: Terminate TLS at the ingress or load balancer. Tokens in `Authorization` headers are sent in the clear without TLS. -- [ ] **Consider gateway-level auth**: For reduced latency and centralized authorization, configure Envoy `ext_authz` to call the PDP at the edge. See [Gateway Integration](/documentation/access-control/deployment/gateway-integration). +- [ ] **Consider gateway bearer-auth callout**: To enforce bearer-token validation before requests reach platform services, configure Envoy `ext_authz` to call `/apis/auth/authenticate`. See [Gateway Integration](/documentation/access-control/deployment/gateway-integration). ## Policy Engine diff --git a/docs/auth/security-model.mdx b/docs/auth/security-model.mdx index 6ec307c2e1..9951ed6ce7 100644 --- a/docs/auth/security-model.mdx +++ b/docs/auth/security-model.mdx @@ -12,29 +12,30 @@ For hands-on setup, see [Auth Configuration](/documentation/access-control/deplo sequenceDiagram participant IdP participant Client - participant Gateway as Gateway / First Service - participant PDP as PDP (Embedded or OPA) + participant Gateway participant Auth as Auth Service - participant Downstream as Downstream Services + participant Service as NeMo Platform Service + participant PDP as PDP (Embedded or OPA) - Note over Gateway,Downstream: Trust Boundary + Note over Gateway,PDP: Trust Boundary Client->>IdP: Validate credentials IdP-->>Client: JWT Client->>Gateway: Authorization: Bearer token - Gateway->>PDP: Check authorization - Auth-->>PDP: Policy + role data - PDP-->>Gateway: Allow / deny - Gateway->>Downstream: Forward trusted X-NMP-Principal-* headers - Gateway->>Downstream: Forward X-NMP-Authorized: true + Gateway->>Auth: /apis/auth/authenticate + Auth-->>Gateway: Trusted X-NMP-Principal-* and X-NMP-Scopes headers + Gateway->>Service: Forward request with trusted headers + Service->>PDP: Check authorization + PDP-->>Service: Allow / deny ``` The request flow: -1. **Client** sends a request with a JWT in the `Authorization: Bearer` header. -2. **Gateway** (or the service itself) validates the JWT signature, issuer, audience, and expiry against the configured OIDC provider. -3. **PDP** (Policy Decision Point) evaluates authorization — checks the principal's role bindings and token scopes against the operation's requirements. -4. If allowed, the service handles the request. In gateway deployments, the gateway forwards the request with trusted `X-NMP-Principal-*` headers so downstream services skip re-validation. +1. **Client** sends a request with a bearer token in the `Authorization` header. +2. In gateway deployments, **Gateway** calls the auth service's `/apis/auth/authenticate` endpoint; otherwise, the first service validates the token directly. +3. The validating component derives trusted `X-NMP-Principal-*` and `X-NMP-Scopes` headers. +4. **PDP** (Policy Decision Point) evaluates authorization — checks the principal's role bindings and token scopes against the operation's requirements. +5. If allowed, the service handles the request. @@ -43,39 +44,40 @@ In quickstart deployments without an OIDC provider, the `X-NMP-Principal-*` head ## Authentication Modes -NeMo Platform supports two authentication modes: **service-level** (the service validates the JWT) and **gateway-level** (the gateway validates at the edge). +NeMo Platform supports two authentication modes: **service-level** bearer-token validation and **gateway bearer-auth callout**. -In both modes, identity arrives as a **JWT** from the client. The JWT is validated exactly once — either by the first NeMo Platform service or by the gateway. After validation, the authenticated identity (email, subject, groups) is propagated to downstream services via **trusted `X-NMP-Principal-*` headers**. Downstream services accept these headers without re-validating the JWT. +In both modes, identity arrives in the `Authorization: Bearer ` header. The bearer token is validated exactly once — either by the first NeMo Platform service or by the auth service behind the gateway callout. After validation, the authenticated identity (email, subject, groups) is propagated to downstream services via **trusted `X-NMP-Principal-*` headers**. Downstream services accept these headers without re-validating the token, but they still run authorization checks. This "validate once, propagate via headers" design means that **network perimeter security is critical**: anything inside the trust boundary that receives `X-NMP-Principal-*` headers will trust them unconditionally. The gateway must strip these headers from all incoming external requests to prevent clients from forging an identity. See [Gateway Integration](/documentation/access-control/deployment/gateway-integration). ### Service-Level Authentication -The first NeMo Platform service that receives the request validates the JWT directly: +The first NeMo Platform service that receives the request validates the bearer token directly: 1. Extracts the `Authorization: Bearer ` header -2. Validates the JWT signature, issuer, audience, and expiry against the configured OIDC provider -3. Extracts the principal identity (email, subject, groups) from JWT claims +2. Validates the token using the enabled auth configuration +3. Extracts the principal identity (email, subject, groups) and scopes 4. Calls the PDP for an authorization decision 5. Forwards `X-NMP-Principal-*` headers to downstream services No gateway is required. This is the simplest mode and the default. -### Gateway-Level Authentication +### Gateway Bearer-Auth Callout -The gateway (e.g., Envoy with `ext_authz`) authenticates and authorizes the request before it reaches any service: +The gateway (e.g., Envoy with `ext_authz`) authenticates the bearer token before forwarding the request: -1. Gateway validates the JWT and calls the PDP -2. On success, sets `X-NMP-Authorized: true` and the `X-NMP-Principal-*` headers -3. Services see `X-NMP-Authorized: true` and skip their own JWT validation and PDP call +1. Gateway calls `/apis/auth/authenticate` with the original `Authorization` header +2. Auth service validates the bearer token and returns trusted `X-NMP-Principal-*` and `X-NMP-Scopes` headers +3. Gateway forwards the request with those trusted headers +4. Services skip bearer-token re-validation and run their normal PDP authorization checks -This rejects unauthorized requests as early as possible, before they reach any service. Services may still call the PDP for fine-grained permission checks (e.g., workspace-level access), but they skip JWT validation and the initial authorization decision. +This rejects unauthenticated requests before they reach platform services while keeping authorization decisions in the normal service middleware path. ## Principal Model A **principal** is an authenticated identity — typically a human user identified by email address. Each request has exactly one principal. -When OIDC is enabled, the principal is resolved from JWT claims: the `sub` claim becomes the principal ID (or `oid` for Azure AD), the `email` claim provides the email (or `upn` for Azure AD), and group memberships come from the `groups` claim. These claim names are configurable. In gateway deployments, the gateway performs this extraction and forwards the result in `X-NMP-Principal-*` headers. +When OIDC is enabled, the principal is resolved from JWT claims: the `sub` claim becomes the principal ID (or `oid` for Azure AD), the `email` claim provides the email (or `upn` for Azure AD), and group memberships come from the `groups` claim. These claim names are configurable. In gateway deployments, the auth service performs this extraction and the gateway forwards the returned `X-NMP-Principal-*` headers. @@ -93,17 +95,15 @@ The following headers carry the authenticated identity through the system: | `X-NMP-Principal-Groups` | Comma-separated list of groups the principal belongs to (from JWT group claims). | | `X-NMP-Scopes` | Space-separated list of token scopes (extracted from the JWT `scp` or `scope` claim). Used by the PDP for scope-based authorization checks. | -These headers are set after JWT validation and forwarded on every internal service-to-service call. As described in [Authentication Modes](#authentication-modes), services inside the trust boundary accept them unconditionally — they are never re-validated. - -Whichever component performs the initial authentication and authorization — the gateway in [gateway-level mode](#gateway-level-authentication), or the first service in [service-level mode](#service-level-authentication) — also sets `X-NMP-Authorized: true`. Downstream services see this header and skip their own JWT validation and PDP call. +These headers are set after bearer-token validation and forwarded on every internal service-to-service call. As described in [Authentication Modes](#authentication-modes), services inside the trust boundary accept them unconditionally — they are never re-validated. Services still call the PDP for authorization when `auth.enabled=true`. ### Service Principals Not all requests originate from human users. Platform services that need cross-workspace access — for example, the jobs controller monitoring jobs across all users, or the evaluator coordinating evaluations — authenticate as **service principals**. -A service principal's ID has the form `service:` (e.g., `service:jobs`, `service:evaluator`). Service principals are auto-authorized without a PDP call and have access to all workspaces and all operations. They are created internally by the platform and are never exposed to external callers. +A service principal's ID has the form `service:` (e.g., `service:jobs`, `service:evaluator`). For normal service routes, service-principal requests still go through the PDP. The policy gives `service:*` principals a default `ServiceSystem` role with broad platform permissions unless explicit policy data narrows that access. Service principals are created internally by the platform and are never exposed to external callers. -**Internal endpoints** (`/internal/*`) are also auto-authorized — they bypass PDP checks entirely and are reserved for service-to-service communication. +PDP endpoints under `/apis/auth/v2/authz/` are the exception: service principals may call them without recursively calling the PDP again. diff --git a/docs/auth/troubleshooting.mdx b/docs/auth/troubleshooting.mdx index 7de925a613..246c7c42a7 100644 --- a/docs/auth/troubleshooting.mdx +++ b/docs/auth/troubleshooting.mdx @@ -56,7 +56,7 @@ When something goes wrong with authentication or authorization, start here. Prob 2. Check your token scopes. Decode the JWT and verify the required scopes are present: ```bash - nemo auth token | cut -d. -f2 | base64 -d 2>/dev/null | python -m json.tool + nemo auth token --decode ``` Look for `scp` or `scope` — ensure `platform:write` is present for write operations. @@ -119,17 +119,19 @@ The `client_id` in NeMo Platform config doesn't match the application in your Id nemo workspaces members list --workspace ``` -## "Gateway-Level Auth Isn't Working" +## "Gateway Bearer-Auth Callout Isn't Working" **Cause**: Headers are not being set or stripped correctly by the gateway. **Diagnosis**: -1. Check that `X-NMP-Authorized: true` is being set by the gateway on allowed requests. If services don't see this header, they fall through to their own PDP call. +1. Check that the gateway calls `/apis/auth/authenticate` with the original `Authorization` header before forwarding protected requests. -2. Check that auth headers are stripped from external requests. Try sending a request with `X-NMP-Authorized: true` from outside the cluster — it should be stripped by the gateway. +2. Check that the gateway forwards the trusted `X-NMP-Principal-*` and `X-NMP-Scopes` headers returned by the auth service on successful authentication. -3. Verify the PDP is reachable from the gateway. If using external OPA, check that the OPA sidecar/service is running and the bundle endpoint is accessible. +3. Check that auth headers are stripped from external requests. Try sending a request with `X-NMP-Principal-Id` or `X-NMP-Scopes` from outside the cluster — those headers should be stripped by the gateway. + +4. Verify the PDP is reachable from platform services. If using external OPA, check that the OPA sidecar/service is running and the bundle endpoint is accessible. ## "Role Change Doesn't Take Effect" diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index ac3acaa3fa..56abb0ada1 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -102,6 +102,193 @@ nemo setup [OPTIONS] * `--help, -h`: Show this message and exit. +### nemo auth + +Manage authentication for NeMo Platform. + +**Usage:** + +```shell +nemo auth [OPTIONS] COMMAND [ARGS]... +``` + +**Help:** + +* `--help, -h`: Show this message and exit. + +**Commands:** + +* `login`: Authenticate with the NeMo Platform cluster. +* `logout`: Remove stored credentials for the current context. +* `refresh`: Refresh the current access token. +* `token`: Print the current access token (for use with SDK or curl). +* `status`: Show current authentication status. +* `access-keys`: Manage NeMo Platform Scoped Access Keys. + +#### nemo auth login + +Authenticate with the NeMo Platform cluster. + +Uses device flow (browser) by default, or password grant when username and password are provided (e.g. for CI). + +For quickstart, use **`--unsigned-token`** to generate an unsigned JWT. + +**Examples:** + +```shell +# Set base URL and log in +nemo auth login --base-url https://nemo.example.com +# Context-specific login +nemo auth login --context dev --base-url https://nemo.dev.example.com +# Device flow, open browser +nemo auth login +# Device flow, show code only +nemo auth login --no-browser +``` + +**Usage:** + +```shell +nemo auth login [OPTIONS] +``` + +**Options:** + +* `--context`: Context to use for this login command. +* `--base-url`: Set cluster base URL for the selected context before login +* `--no-browser`: Don't open browser (device flow only) +* `--scope`: OAuth scopes to request (space-separated; quote for multiple, e.g. --scope "platform:read secrets:write") +* `--username`: Username for password grant (CI / non-interactive) +* `--password`: Password for password grant (prefer env NMP_OIDC_PASSWORD) + +**Help:** + +* `--help, -h`: Show this message and exit. + +**Unsigned Token Options:** + +* `--unsigned-token`: Generate and save an unsigned JWT for local/testing authentication. +* `--principal-id`: Principal ID for the unsigned token (`sub` claim). Defaults to --email. +* `--email`: Email claim for the unsigned token (required with --unsigned-token). +* `--group`: Group claim value for unsigned token (repeat for multiple). +* `--expires-in `: Unsigned token expiry in seconds from now. [default: 3600] +* `--no-exp`: Omit the exp claim from the unsigned token. +* `--audience`: Audience (`aud`) claim for unsigned token. +* `--issuer`: Issuer (`iss`) claim for unsigned token. + +#### nemo auth logout + +Remove stored credentials for the current context. + +**Usage:** + +```shell +nemo auth logout [OPTIONS] +``` + +**Help:** + +* `--help, -h`: Show this message and exit. + +#### nemo auth refresh + +Refresh the current access token. + +This command uses the saved refresh token to obtain a new access token without requiring you to re-authenticate through the browser. + +**Usage:** + +```shell +nemo auth refresh [OPTIONS] +``` + +**Help:** + +* `--help, -h`: Show this message and exit. + +#### nemo auth token + +Print the current access token (for use with SDK or curl). + +By default this outputs the raw token to stdout, suitable for piping or capture. + +**Examples:** + +```shell +# Print token +nemo auth token +# Inspect token claims +nemo auth token --decode +# Capture in env var +export TOKEN=$(nemo auth token) +curl -H "Authorization: Bearer $(nemo auth token)" ... +``` + +**Usage:** + +```shell +nemo auth token [OPTIONS] +``` + +**Options:** + +* `--decode`: Decode the JWT payload claims as JSON. This does not verify the token signature. + +**Help:** + +* `--help, -h`: Show this message and exit. + +#### nemo auth status + +Show current authentication status. + +**Usage:** + +```shell +nemo auth status [OPTIONS] +``` + +**Help:** + +* `--help, -h`: Show this message and exit. + +#### nemo auth access-keys + +Manage NeMo Platform Scoped Access Keys. + +**Usage:** + +```shell +nemo auth access-keys [OPTIONS] COMMAND [ARGS]... +``` + +**Help:** + +* `--help, -h`: Show this message and exit. + +**Commands:** + +* `create`: Create a Scoped Access Key for the current authenticated... + +##### nemo auth access-keys create + +Create a Scoped Access Key for the current authenticated user. + +**Usage:** + +```shell +nemo auth access-keys create [OPTIONS] +``` + +**Options:** + +* `--name, -n`: Optional human-readable label for the Scoped Access Key. +* `--expires-in`: Scoped Access Key lifetime in seconds. Use 'none' to request no expiration. + +**Help:** + +* `--help, -h`: Show this message and exit. + ### nemo services Run platform services locally. diff --git a/docs/fern/snippets/_snippets/cli-summary.mdx b/docs/fern/snippets/_snippets/cli-summary.mdx index 90d506c1f4..80c6b1c5aa 100644 --- a/docs/fern/snippets/_snippets/cli-summary.mdx +++ b/docs/fern/snippets/_snippets/cli-summary.mdx @@ -17,7 +17,7 @@ description: "" | Category | Commands | Description | |----------|----------|-------------| -| Setup | `setup`, `services`, `skills` | Set up and run local platform components | +| Setup | `setup`, `auth`, `services`, `skills` | Set up and run local platform components | | CLI functions | `chat`, `docs`, `wait`, `agent`, `plugins` | Interactive, documentation, and agent-oriented workflows | | Core plugins | `files`, `inference`, `jobs`, `models`, `secrets`, `workspaces` | Core platform resources | | Functional plugins | `guardrail` | Functional service and plugin commands | diff --git a/docs/set-up/config-reference.mdx b/docs/set-up/config-reference.mdx index cf2c95eb95..e208dbcf73 100644 --- a/docs/set-up/config-reference.mdx +++ b/docs/set-up/config-reference.mdx @@ -117,8 +117,8 @@ auth: workload_token_issuer: # Lifetime in seconds for workload identity access tokens minted by the NeMo auth service. | default: 300 workload_token_ttl_seconds: 300 - # JWT key id advertised by the NeMo auth service workload identity JWKS endpoint. | default: 'nemo-workload-exchange' - workload_token_key_id: nemo-workload-exchange + # Workload-specific JWT key id advertised by the NeMo auth service workload identity JWKS endpoint. When unset, workload token exchange uses auth.token_signing.key_id. + workload_token_key_id: # Path to a PEM-encoded RSA private key used by the NeMo auth service to sign workload identity access tokens. Intended for mounted shared secrets. workload_token_private_key_file: # Additional RFC 8693 audience values accepted by the NeMo auth service workload token exchange endpoint. The configured workload_audience is always accepted. @@ -135,6 +135,29 @@ auth: scope_prefix: # TTL in seconds for caching IdP discovery document responses. Used by the discovery endpoint to avoid per-request IdP calls. Set to 0 to disable caching. | default: 300 discovery_cache_ttl: 300 + # Shared token signing configuration for NeMo Platform-minted JWTs. + token_signing: + # Shared issuer for NeMo Platform-minted JWTs. Defaults to /apis/auth. + issuer: + # Shared JWT key id advertised by NeMo Platform JWKS endpoints. | default: 'nemo-platform-signing' + key_id: nemo-platform-signing + # Path to the PEM-encoded RSA private key used by the auth service to sign NeMo Platform-minted JWTs. Intended for mounted shared secrets. + private_key_file: + # Scoped Access Key configuration. + access_keys: + # Enable NeMo Platform Scoped Access Key creation and validation. | default: False + enabled: false + # Token format to issue for newly created Scoped Access Keys. | default: 'jwt' | values: 'jwt' + issue_format: jwt + # Scoped Access Key token formats accepted by validators. + accepted_formats: + - jwt + # Expected audience for NeMo Platform Scoped Access Key JWTs. | default: 'nemo-platform-access-key' + audience: nemo-platform-access-key + # Default finite lifetime in seconds for newly created Scoped Access Keys when expires_in_seconds is omitted. Set to null to require callers to provide an expiry when max_expires_in_seconds is finite, or to make omitted expiry non-time-delimited when max_expires_in_seconds is also null. | default: 2592000 + default_expires_in_seconds: 2592000 + # Maximum finite lifetime accepted when creating Scoped Access Keys. Set to null to allow explicit no-expiration requests. | default: 2592000 + max_expires_in_seconds: 2592000 # Port to run the service on | default: 8000 port: 8000 # Refresh interval for policy data in seconds | default: 30 diff --git a/e2e/authz_oidc/conftest.py b/e2e/authz_oidc/conftest.py index aa26f40f50..f4cdd32f86 100644 --- a/e2e/authz_oidc/conftest.py +++ b/e2e/authz_oidc/conftest.py @@ -144,9 +144,9 @@ def _platform_env(issuer_url: str, base_url: str, data_dir: Path, extra: dict[st "NMP_AUTH_ENABLED": "true", "NMP_AUTH_ALLOW_UNSIGNED_JWT": "false", # defaults are true; signed JWTs only "NMP_AUTH_POLICY_DECISION_POINT_BASE_URL": base_url, - "NMP_AUTH_OIDC_ENABLED": "true", - "NMP_AUTH_OIDC_ISSUER": issuer_url, - "NMP_AUTH_OIDC_AUDIENCE": DEFAULT_AUDIENCE, + "NMP_AUTH_OIDC__ENABLED": "true", + "NMP_AUTH_OIDC__ISSUER": issuer_url, + "NMP_AUTH_OIDC__AUDIENCE": DEFAULT_AUDIENCE, "NMP_AUTH_ADMIN_EMAIL": ADMIN_EMAIL, # bundle_cache_seconds must stay NONZERO: at 0 every PDP eval # rebuilds policy data, and degraded fixture plugins are never diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml index 359cbae96a..8be64a3de8 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -4,6 +4,55 @@ info: description: API for Nemo Platform services version: 0.0.0 paths: + /apis/auth/authenticate: + get: + tags: + - Authentication + summary: Authenticate Bearer Token Get + operationId: get_authenticate_bearer_token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateResponse' + '401': + description: Missing or invalid bearer token + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + '500': + description: Bearer token authentication is misconfigured + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + post: + tags: + - Authentication + summary: Authenticate Bearer Token Post + operationId: post_authenticate_bearer_token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateResponse' + '401': + description: Missing or invalid bearer token + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + '500': + description: Bearer token authentication is misconfigured + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' /apis/auth/discovery: get: tags: @@ -117,6 +166,110 @@ paths: application/json: schema: $ref: '#/components/schemas/WorkloadTokenExchangeErrorResponse' + /apis/auth/v2/access-keys: + get: + tags: + - Scoped Access Keys + summary: List Access Keys + operationId: list_access_keys_apis_auth_v2_access_keys_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyListResponse' + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + post: + tags: + - Scoped Access Keys + summary: Create Access Key + operationId: create_access_key_apis_auth_v2_access_keys_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateResponse' + '400': + description: Scoped Access Key creation error + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}: + delete: + tags: + - Scoped Access Keys + summary: Revoke Access Key + operationId: revoke_access_key_apis_auth_v2_access_keys__jti__delete + parameters: + - name: jti + in: path + required: true + schema: + type: string + title: Jti + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /apis/auth/v2/iam/role-bindings: get: tags: @@ -7764,6 +7917,124 @@ components: type: object title: APIEndpointData description: Data about an inference endpoint. + AccessKeyCreateRequest: + properties: + name: + title: Name + description: Optional human-readable Scoped Access Key label. The token + jti remains the stable identifier. + type: string + maxLength: 128 + minLength: 1 + expires_in_seconds: + title: Expires In Seconds + description: Scoped Access Key lifetime in seconds. Omit to use auth.access_keys.default_expires_in_seconds. + Send explicit null to request a non-time-delimited key, which requires + auth.access_keys.max_expires_in_seconds to be disabled. + type: integer + minimum: 1.0 + type: object + title: AccessKeyCreateRequest + description: Request body for creating a Scoped Access Key. + AccessKeyCreateResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + name: + title: Name + description: Optional human-readable Scoped Access Key label. + type: string + principal: + type: string + title: Principal + description: Principal ID stamped into the token. + created_at: + type: string + format: date-time + title: Created At + expires_at: + title: Expires At + type: string + format: date-time + token: + type: string + title: Token + token_type: + type: string + const: Bearer + title: Token Type + type: object + required: + - jti + - principal + - created_at + - token + - token_type + title: AccessKeyCreateResponse + description: Create response. The token value is returned only once. + AccessKeyErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AccessKeyErrorResponse + description: Scoped Access Key error response. + AccessKeyListResponse: + properties: + data: + items: + $ref: '#/components/schemas/AccessKeyMetadataResponse' + type: array + title: Data + type: object + required: + - data + title: AccessKeyListResponse + description: List response for Scoped Access Key metadata. + AccessKeyMetadataResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + name: + title: Name + description: Optional human-readable Scoped Access Key label. + type: string + principal: + type: string + title: Principal + description: Principal ID stamped into the token. + created_at: + type: string + format: date-time + title: Created At + expires_at: + title: Expires At + type: string + format: date-time + type: object + required: + - jti + - principal + - created_at + title: AccessKeyMetadataResponse + description: Metadata for a Scoped Access Key. + AccessKeyNotImplementedErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AccessKeyNotImplementedErrorResponse + description: Response returned by unsupported Scoped Access Key lifecycle endpoints. ActionRails: properties: instant_actions: @@ -8627,6 +8898,53 @@ components: - auth_enabled title: AuthDiscoveryResponse description: Auth discovery response for CLI/SDK. + AuthenticateErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AuthenticateErrorResponse + description: Bearer token authentication error response. + AuthenticateResponse: + properties: + principal: + type: string + title: Principal + email: + title: Email + nullable: true + type: string + groups: + items: + type: string + type: array + title: Groups + scopes: + items: + type: string + type: array + title: Scopes + jti: + title: Jti + nullable: true + type: string + token_kind: + type: string + enum: + - access_key + - oidc_access_token + - workload_access_token + - workload_subject_token + title: Token Kind + type: object + required: + - principal + - token_kind + title: AuthenticateResponse + description: Successful bearer token authentication response for auth callouts. AutoAlignOptions: properties: guardrails_config: diff --git a/openapi/ga/openapi.yaml b/openapi/ga/openapi.yaml index 359cbae96a..8be64a3de8 100644 --- a/openapi/ga/openapi.yaml +++ b/openapi/ga/openapi.yaml @@ -4,6 +4,55 @@ info: description: API for Nemo Platform services version: 0.0.0 paths: + /apis/auth/authenticate: + get: + tags: + - Authentication + summary: Authenticate Bearer Token Get + operationId: get_authenticate_bearer_token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateResponse' + '401': + description: Missing or invalid bearer token + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + '500': + description: Bearer token authentication is misconfigured + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + post: + tags: + - Authentication + summary: Authenticate Bearer Token Post + operationId: post_authenticate_bearer_token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateResponse' + '401': + description: Missing or invalid bearer token + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + '500': + description: Bearer token authentication is misconfigured + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' /apis/auth/discovery: get: tags: @@ -117,6 +166,110 @@ paths: application/json: schema: $ref: '#/components/schemas/WorkloadTokenExchangeErrorResponse' + /apis/auth/v2/access-keys: + get: + tags: + - Scoped Access Keys + summary: List Access Keys + operationId: list_access_keys_apis_auth_v2_access_keys_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyListResponse' + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + post: + tags: + - Scoped Access Keys + summary: Create Access Key + operationId: create_access_key_apis_auth_v2_access_keys_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateResponse' + '400': + description: Scoped Access Key creation error + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}: + delete: + tags: + - Scoped Access Keys + summary: Revoke Access Key + operationId: revoke_access_key_apis_auth_v2_access_keys__jti__delete + parameters: + - name: jti + in: path + required: true + schema: + type: string + title: Jti + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /apis/auth/v2/iam/role-bindings: get: tags: @@ -7764,6 +7917,124 @@ components: type: object title: APIEndpointData description: Data about an inference endpoint. + AccessKeyCreateRequest: + properties: + name: + title: Name + description: Optional human-readable Scoped Access Key label. The token + jti remains the stable identifier. + type: string + maxLength: 128 + minLength: 1 + expires_in_seconds: + title: Expires In Seconds + description: Scoped Access Key lifetime in seconds. Omit to use auth.access_keys.default_expires_in_seconds. + Send explicit null to request a non-time-delimited key, which requires + auth.access_keys.max_expires_in_seconds to be disabled. + type: integer + minimum: 1.0 + type: object + title: AccessKeyCreateRequest + description: Request body for creating a Scoped Access Key. + AccessKeyCreateResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + name: + title: Name + description: Optional human-readable Scoped Access Key label. + type: string + principal: + type: string + title: Principal + description: Principal ID stamped into the token. + created_at: + type: string + format: date-time + title: Created At + expires_at: + title: Expires At + type: string + format: date-time + token: + type: string + title: Token + token_type: + type: string + const: Bearer + title: Token Type + type: object + required: + - jti + - principal + - created_at + - token + - token_type + title: AccessKeyCreateResponse + description: Create response. The token value is returned only once. + AccessKeyErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AccessKeyErrorResponse + description: Scoped Access Key error response. + AccessKeyListResponse: + properties: + data: + items: + $ref: '#/components/schemas/AccessKeyMetadataResponse' + type: array + title: Data + type: object + required: + - data + title: AccessKeyListResponse + description: List response for Scoped Access Key metadata. + AccessKeyMetadataResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + name: + title: Name + description: Optional human-readable Scoped Access Key label. + type: string + principal: + type: string + title: Principal + description: Principal ID stamped into the token. + created_at: + type: string + format: date-time + title: Created At + expires_at: + title: Expires At + type: string + format: date-time + type: object + required: + - jti + - principal + - created_at + title: AccessKeyMetadataResponse + description: Metadata for a Scoped Access Key. + AccessKeyNotImplementedErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AccessKeyNotImplementedErrorResponse + description: Response returned by unsupported Scoped Access Key lifecycle endpoints. ActionRails: properties: instant_actions: @@ -8627,6 +8898,53 @@ components: - auth_enabled title: AuthDiscoveryResponse description: Auth discovery response for CLI/SDK. + AuthenticateErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AuthenticateErrorResponse + description: Bearer token authentication error response. + AuthenticateResponse: + properties: + principal: + type: string + title: Principal + email: + title: Email + nullable: true + type: string + groups: + items: + type: string + type: array + title: Groups + scopes: + items: + type: string + type: array + title: Scopes + jti: + title: Jti + nullable: true + type: string + token_kind: + type: string + enum: + - access_key + - oidc_access_token + - workload_access_token + - workload_subject_token + title: Token Kind + type: object + required: + - principal + - token_kind + title: AuthenticateResponse + description: Successful bearer token authentication response for auth callouts. AutoAlignOptions: properties: guardrails_config: diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 359cbae96a..8be64a3de8 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -4,6 +4,55 @@ info: description: API for Nemo Platform services version: 0.0.0 paths: + /apis/auth/authenticate: + get: + tags: + - Authentication + summary: Authenticate Bearer Token Get + operationId: get_authenticate_bearer_token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateResponse' + '401': + description: Missing or invalid bearer token + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + '500': + description: Bearer token authentication is misconfigured + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + post: + tags: + - Authentication + summary: Authenticate Bearer Token Post + operationId: post_authenticate_bearer_token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateResponse' + '401': + description: Missing or invalid bearer token + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + '500': + description: Bearer token authentication is misconfigured + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' /apis/auth/discovery: get: tags: @@ -117,6 +166,110 @@ paths: application/json: schema: $ref: '#/components/schemas/WorkloadTokenExchangeErrorResponse' + /apis/auth/v2/access-keys: + get: + tags: + - Scoped Access Keys + summary: List Access Keys + operationId: list_access_keys_apis_auth_v2_access_keys_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyListResponse' + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + post: + tags: + - Scoped Access Keys + summary: Create Access Key + operationId: create_access_key_apis_auth_v2_access_keys_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateResponse' + '400': + description: Scoped Access Key creation error + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}: + delete: + tags: + - Scoped Access Keys + summary: Revoke Access Key + operationId: revoke_access_key_apis_auth_v2_access_keys__jti__delete + parameters: + - name: jti + in: path + required: true + schema: + type: string + title: Jti + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /apis/auth/v2/iam/role-bindings: get: tags: @@ -7764,6 +7917,124 @@ components: type: object title: APIEndpointData description: Data about an inference endpoint. + AccessKeyCreateRequest: + properties: + name: + title: Name + description: Optional human-readable Scoped Access Key label. The token + jti remains the stable identifier. + type: string + maxLength: 128 + minLength: 1 + expires_in_seconds: + title: Expires In Seconds + description: Scoped Access Key lifetime in seconds. Omit to use auth.access_keys.default_expires_in_seconds. + Send explicit null to request a non-time-delimited key, which requires + auth.access_keys.max_expires_in_seconds to be disabled. + type: integer + minimum: 1.0 + type: object + title: AccessKeyCreateRequest + description: Request body for creating a Scoped Access Key. + AccessKeyCreateResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + name: + title: Name + description: Optional human-readable Scoped Access Key label. + type: string + principal: + type: string + title: Principal + description: Principal ID stamped into the token. + created_at: + type: string + format: date-time + title: Created At + expires_at: + title: Expires At + type: string + format: date-time + token: + type: string + title: Token + token_type: + type: string + const: Bearer + title: Token Type + type: object + required: + - jti + - principal + - created_at + - token + - token_type + title: AccessKeyCreateResponse + description: Create response. The token value is returned only once. + AccessKeyErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AccessKeyErrorResponse + description: Scoped Access Key error response. + AccessKeyListResponse: + properties: + data: + items: + $ref: '#/components/schemas/AccessKeyMetadataResponse' + type: array + title: Data + type: object + required: + - data + title: AccessKeyListResponse + description: List response for Scoped Access Key metadata. + AccessKeyMetadataResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + name: + title: Name + description: Optional human-readable Scoped Access Key label. + type: string + principal: + type: string + title: Principal + description: Principal ID stamped into the token. + created_at: + type: string + format: date-time + title: Created At + expires_at: + title: Expires At + type: string + format: date-time + type: object + required: + - jti + - principal + - created_at + title: AccessKeyMetadataResponse + description: Metadata for a Scoped Access Key. + AccessKeyNotImplementedErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AccessKeyNotImplementedErrorResponse + description: Response returned by unsupported Scoped Access Key lifecycle endpoints. ActionRails: properties: instant_actions: @@ -8627,6 +8898,53 @@ components: - auth_enabled title: AuthDiscoveryResponse description: Auth discovery response for CLI/SDK. + AuthenticateErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AuthenticateErrorResponse + description: Bearer token authentication error response. + AuthenticateResponse: + properties: + principal: + type: string + title: Principal + email: + title: Email + nullable: true + type: string + groups: + items: + type: string + type: array + title: Groups + scopes: + items: + type: string + type: array + title: Scopes + jti: + title: Jti + nullable: true + type: string + token_kind: + type: string + enum: + - access_key + - oidc_access_token + - workload_access_token + - workload_subject_token + title: Token Kind + type: object + required: + - principal + - token_kind + title: AuthenticateResponse + description: Successful bearer token authentication response for auth callouts. AutoAlignOptions: properties: guardrails_config: diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py index 93ed4f13cb..9a2d6d009d 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/auth.py @@ -10,13 +10,21 @@ from __future__ import annotations import asyncio +import json import logging import os import time -from typing import Annotated, cast +from typing import Annotated, NoReturn, cast import httpx import typer +from nemo_platform_plugin.auth.access_keys.client import AccessKeyIssuerClient, AccessKeysClient +from nemo_platform_plugin.auth.access_keys.issuer import ( + AccessKeyFeatureDisabledError, + AccessKeyOperationNotImplementedError, +) +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest +from nemo_platform_plugin.client.adapter import client_from_platform from rich.console import Console from nemo_platform_ext.auth.helpers import ( @@ -40,10 +48,47 @@ name="auth", help="Manage authentication for NeMo Platform.", ) +access_keys_app = create_typer_app( + name="access-keys", + help="Manage NeMo Platform Scoped Access Keys.", +) +app.add_typer(access_keys_app, name="access-keys") logger = logging.getLogger(__name__) +def _access_key_issuer(ctx: typer.Context) -> AccessKeyIssuerClient: + state: CLIContext = ctx.obj + access_keys_client = client_from_platform(state.get_client(), AccessKeysClient) + return AccessKeyIssuerClient(access_keys_client) + + +def _raise_access_key_not_implemented(exc: AccessKeyOperationNotImplementedError) -> NoReturn: + raise AuthError(str(exc) or "Scoped Access Key operation is not implemented.") from exc + + +def _raise_access_key_disabled(exc: AccessKeyFeatureDisabledError) -> NoReturn: + raise AuthError(str(exc) or "Scoped Access Keys are not enabled.") from exc + + +def _parse_access_key_expires_in(value: str | None) -> tuple[bool, int | None]: + if value is None: + return False, None + + normalized = value.strip().lower() + if normalized in {"none", "null"}: + return True, None + + try: + expires_in_seconds = int(value) + except ValueError as exc: + raise AuthError("--expires-in must be a positive integer number of seconds or 'none'.") from exc + + if expires_in_seconds < 1: + raise AuthError("--expires-in must be a positive integer number of seconds or 'none'.") + return True, expires_in_seconds + + def is_auth_disabled(base_url: str, timeout: float = 3.0) -> bool: """Check whether authentication is disabled on the cluster. @@ -697,7 +742,7 @@ def refresh(ctx: typer.Context) -> None: try: oidc_config = discover_nmp_config(base_url) except httpx.HTTPError as e: - raise AuthError(f"Failed to discover auth configuration: {e}") + raise AuthError(f"Failed to discover auth configuration: {e}") from e if not oidc_config.client_id or not oidc_config.token_endpoint: raise AuthError("OIDC not configured on cluster.") @@ -741,14 +786,25 @@ def refresh(ctx: typer.Context) -> None: @app.command("token") @handle_errors -def token(ctx: typer.Context) -> None: +def token( + ctx: typer.Context, + decode: Annotated[ + bool, + typer.Option( + "--decode", + help="Decode the JWT payload claims as JSON. This does not verify the token signature.", + ), + ] = False, +) -> None: """Print the current access token (for use with SDK or curl). - This outputs the raw token to stdout, suitable for piping or capture. + By default this outputs the raw token to stdout, suitable for piping or capture. Examples: # Print token nemo auth token + # Inspect token claims + nemo auth token --decode # Capture in env var export TOKEN=$(nemo auth token) curl -H "Authorization: Bearer $(nemo auth token)" ... @@ -762,11 +818,49 @@ def token(ctx: typer.Context) -> None: raise AuthError("No authentication configured. Run 'nemo auth login' first.") if isinstance(context.user, OAuthUser): - typer.echo(context.user.token.get_secret_value()) + access_token = context.user.token.get_secret_value() + if decode: + claims = decode_jwt_claims(access_token) + if not claims: + raise AuthError("Current access token is not a decodable JWT.") + typer.echo(json.dumps(claims, indent=2)) + return + typer.echo(access_token) else: raise AuthError("No token available for current user type.") +@access_keys_app.command("create") +@handle_errors +def create_access_key( + ctx: typer.Context, + name: Annotated[ + str | None, + typer.Option("--name", "-n", help="Optional human-readable label for the Scoped Access Key."), + ] = None, + expires_in: Annotated[ + str | None, + typer.Option( + "--expires-in", + help="Scoped Access Key lifetime in seconds. Use 'none' to request no expiration.", + ), + ] = None, +) -> None: + """Create a Scoped Access Key for the current authenticated user.""" + expires_in_was_set, parsed_expires_in = _parse_access_key_expires_in(expires_in) + if expires_in_was_set: + request = AccessKeyCreateRequest(name=name, expires_in_seconds=parsed_expires_in) + else: + request = AccessKeyCreateRequest(name=name) + try: + created = _access_key_issuer(ctx).create(request) + except AccessKeyFeatureDisabledError as exc: + _raise_access_key_disabled(exc) + except AccessKeyOperationNotImplementedError as exc: + _raise_access_key_not_implemented(exc) + typer.echo(created.token) + + @app.command("status") @handle_errors def status(ctx: typer.Context) -> None: diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py index 13d5013840..962452d81d 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/manifest_registry.py @@ -12,7 +12,6 @@ name="auth", panel="Setup", kind="group", - hidden=True, ), TopLevelEntry( import_path="nemo_platform_ext.cli.commands.config:app", diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/manifest.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/manifest.py index bc691fd299..7a2da98a76 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/manifest.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/manifest.py @@ -30,7 +30,7 @@ } TOP_LEVEL_COMMAND_ORDER: dict[PanelName, tuple[str, ...]] = { - "Setup": ("setup", "services", "skills"), + "Setup": ("setup", "auth", "services", "skills"), "CLI functions": ("chat", "docs", "wait", "agent", "plugins"), "Core plugins": ("files", "inference", "jobs", "models", "secrets", "workspaces"), "Functional plugins": ("agents", "data-designer", "guardrail", "audit", "anonymizer", "evaluator"), diff --git a/packages/nemo_platform_ext/tests/cli/commands/test_agent.py b/packages/nemo_platform_ext/tests/cli/commands/test_agent.py index 4fc204b1a0..0d53ec5590 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_agent.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_agent.py @@ -176,6 +176,7 @@ def test_commands_uses_visible_command_order_with_discovered_plugins(self): command_rows = [line for line in result.stdout.splitlines() if line.startswith("| nemo ")] assert command_rows == [ "| nemo setup | Setup | Set up NeMo Platform: connect or start services, configure a provider, install skills. |", + "| nemo auth | Setup | Manage authentication for NeMo Platform. |", "| nemo services | Setup | Run platform services locally. |", "| nemo skills | Setup | Install AI agent skill files for Nemo. |", "| nemo chat | CLI functions | Start an interactive chat session with a model. |", diff --git a/packages/nemo_platform_ext/tests/cli/commands/test_auth.py b/packages/nemo_platform_ext/tests/cli/commands/test_auth.py index d126590641..eb5cf5a96c 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_auth.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_auth.py @@ -1,16 +1,20 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import json import logging from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest import yaml from nemo_platform_ext.auth.helpers import decode_jwt_claims, generate_unsigned_jwt from nemo_platform_ext.cli.app import app +from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyFeatureDisabledError +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest, AccessKeyCreateResponse from nemo_platform_plugin.client.constants import WORKLOAD_IDENTITY_TOKEN_FILE_ENVVAR from typer.testing import CliRunner @@ -62,6 +66,18 @@ def _decode_jwt_noop(token: str) -> dict: return {} +def _created_access_key(name: str | None = None) -> AccessKeyCreateResponse: + return AccessKeyCreateResponse( + jti="ak_example", + name=name, + token="signed.jwt.token", + token_type="Bearer", + principal="alice@example.com", + created_at=datetime(2026, 7, 28, 12, 0, tzinfo=UTC), + expires_at=None, + ) + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -119,6 +135,52 @@ def oauth_config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return config_path +# --------------------------------------------------------------------------- +# token +# --------------------------------------------------------------------------- + + +def test_auth_token_prints_raw_token(oauth_config_file: Path) -> None: + result = runner.invoke(app, ["--context", "foo", "auth", "token"]) + + assert_exit_code(result, 0) + assert result.output == "foo-token\n" + + +def test_auth_token_decode_prints_claims_json(oauth_config_file: Path) -> None: + token = generate_unsigned_jwt( + principal_id="alice@example.com", + email="alice@example.com", + groups=["team-ml"], + scopes=["openid", "email"], + extra_claims={"iss": "https://idp.example.com"}, + ) + with open(oauth_config_file) as f: + config_data = yaml.safe_load(f) + for user in config_data["users"]: + if user["name"] == "foo": + user["token"] = token + with open(oauth_config_file, "w") as f: + yaml.safe_dump(config_data, f) + + result = runner.invoke(app, ["--context", "foo", "auth", "token", "--decode"]) + + assert_exit_code(result, 0) + claims = json.loads(result.output) + assert claims["sub"] == "alice@example.com" + assert claims["email"] == "alice@example.com" + assert claims["groups"] == ["team-ml"] + assert claims["scope"] == "openid email" + assert claims["iss"] == "https://idp.example.com" + + +def test_auth_token_decode_rejects_malformed_token(oauth_config_file: Path) -> None: + result = runner.invoke(app, ["--context", "foo", "auth", "token", "--decode"]) + + assert_exit_code(result, 1) + assert "Current access token is not a decodable JWT" in result.output + + # --------------------------------------------------------------------------- # logout # --------------------------------------------------------------------------- @@ -284,6 +346,136 @@ def test_auth_refresh_regenerates_unsigned_token(oauth_config_file: Path) -> Non assert refreshed_user.get("refresh_token") is None +def test_auth_access_keys_create_prints_token(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("NMP_BASE_URL", "https://cluster.example.com") + + fake_platform_client = MagicMock() + fake_access_keys_client = MagicMock() + fake_access_keys_client.create_access_key.return_value.data.return_value = _created_access_key() + + monkeypatch.setattr("nemo_platform_ext.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) + monkeypatch.setattr( + "nemo_platform_ext.cli.commands.auth.client_from_platform", + lambda platform, client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "create"]) + + assert_exit_code(result, 0) + assert result.output.strip() == "signed.jwt.token" + body = fake_access_keys_client.create_access_key.call_args.kwargs["body"] + assert body == AccessKeyCreateRequest() + assert "expires_in_seconds" not in body.model_fields_set + + +def test_auth_access_keys_create_sends_optional_name_and_expiration(monkeypatch: pytest.MonkeyPatch): + fake_platform_client = MagicMock() + fake_access_keys_client = MagicMock() + fake_access_keys_client.create_access_key.return_value.data.return_value = _created_access_key("short-lived") + monkeypatch.setattr("nemo_platform_ext.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) + monkeypatch.setattr( + "nemo_platform_ext.cli.commands.auth.client_from_platform", + lambda platform, client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "create", "--name", "short-lived", "--expires-in", "3600"]) + + assert_exit_code(result, 0) + fake_access_keys_client.create_access_key.assert_called_once_with( + body=AccessKeyCreateRequest(name="short-lived", expires_in_seconds=3600), + ) + + +def test_auth_access_keys_create_sends_explicit_null_expiration(monkeypatch: pytest.MonkeyPatch): + fake_platform_client = MagicMock() + fake_access_keys_client = MagicMock() + fake_access_keys_client.create_access_key.return_value.data.return_value = _created_access_key("long-lived") + monkeypatch.setattr("nemo_platform_ext.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) + monkeypatch.setattr( + "nemo_platform_ext.cli.commands.auth.client_from_platform", + lambda platform, client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "create", "--name", "long-lived", "--expires-in", "none"]) + + assert_exit_code(result, 0) + body = fake_access_keys_client.create_access_key.call_args.kwargs["body"] + assert body == AccessKeyCreateRequest(name="long-lived", expires_in_seconds=None) + assert "expires_in_seconds" in body.model_fields_set + + +def test_auth_access_keys_create_rejects_invalid_expiration(monkeypatch: pytest.MonkeyPatch): + fake_platform_client = MagicMock() + fake_access_keys_client = MagicMock() + monkeypatch.setattr("nemo_platform_ext.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) + monkeypatch.setattr( + "nemo_platform_ext.cli.commands.auth.client_from_platform", + lambda platform, client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "create", "--expires-in", "zero"]) + + assert_exit_code(result, 1) + assert "--expires-in must be a positive integer number of seconds" in result.output + assert "'none'." in result.output + fake_access_keys_client.create_access_key.assert_not_called() + + +def test_auth_access_keys_create_reports_disabled_feature(monkeypatch: pytest.MonkeyPatch): + fake_platform_client = MagicMock() + fake_access_keys_client = MagicMock() + fake_access_keys_client.create_access_key.side_effect = AccessKeyFeatureDisabledError( + "Scoped Access Keys are not enabled" + ) + monkeypatch.setattr("nemo_platform_ext.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) + monkeypatch.setattr( + "nemo_platform_ext.cli.commands.auth.client_from_platform", + lambda platform, client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "create"]) + + assert result.exit_code == 1 + assert "Scoped Access Keys are not enabled" in result.output + + +def test_auth_access_keys_help_hides_unimplemented_lifecycle_commands() -> None: + result = runner.invoke(app, ["auth", "access-keys", "--help"]) + + assert_exit_code(result, 0) + assert "create" in result.output + assert "list" not in result.output + assert "revoke" not in result.output + + create_help = runner.invoke(app, ["auth", "access-keys", "create", "--help"]) + assert_exit_code(create_help, 0) + assert "Use 'none' to request no expiration" in " ".join(create_help.output.split()) + + list_result = runner.invoke(app, ["auth", "access-keys", "list"]) + revoke_result = runner.invoke(app, ["auth", "access-keys", "revoke", "ak_example"]) + + assert list_result.exit_code != 0 + assert revoke_result.exit_code != 0 + assert "No such command" in list_result.output + assert "No such command" in revoke_result.output + + +def test_auth_tokens_group_is_not_exposed() -> None: + result = runner.invoke(app, ["auth", "tokens", "create"]) + + assert result.exit_code != 0 + assert "No such command" in result.output + assert "tokens" in result.output + + +def test_top_level_access_keys_group_is_not_exposed() -> None: + result = runner.invoke(app, ["access-keys", "--help"]) + + assert result.exit_code != 0 + assert "No such command" in result.output + assert "access-keys" in result.output + + # --------------------------------------------------------------------------- # status # --------------------------------------------------------------------------- diff --git a/packages/nemo_platform_ext/tests/cli/test_app.py b/packages/nemo_platform_ext/tests/cli/test_app.py index ac6c527df1..1accbb1b3c 100644 --- a/packages/nemo_platform_ext/tests/cli/test_app.py +++ b/packages/nemo_platform_ext/tests/cli/test_app.py @@ -265,11 +265,12 @@ def test_root_help_excludes_hidden_commands_and_context_option(): assert result.exit_code == 0 assert "--context" not in result.stdout - for hidden_command in ("auth", "config", "quickstart", "cluster-info", "adapters", "projects"): + assert "\n auth" in result.stdout + for hidden_command in ("config", "quickstart", "cluster-info", "adapters", "projects"): assert f"\n {hidden_command}" not in result.stdout -def test_hidden_command_and_context_option_remain_invokable(): +def test_auth_command_and_hidden_context_option_remain_invokable(): runner = CliRunner() qs_config = QuickstartConfig(auth_enabled=False) diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/__init__.py similarity index 100% rename from packages/nemo_platform_plugin/src/nemo_platform_plugin/auth.py rename to packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/__init__.py diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/client.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/client.py new file mode 100644 index 0000000000..9bce55aa4d --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/client.py @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from nemo_platform_plugin.auth.access_keys import endpoints +from nemo_platform_plugin.auth.access_keys.issuer import ( + AccessKeyFeatureDisabledError, + AccessKeyIssuer, + AccessKeyOperationNotImplementedError, +) +from nemo_platform_plugin.auth.access_keys.types import ( + AccessKeyCreateRequest, + AccessKeyCreateResponse, + AccessKeyListResponse, +) +from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient +from nemo_platform_plugin.client.errors import NemoHTTPError +from nemo_platform_plugin.client.method import method + + +class _AccessKeyMethods: + create_access_key = method(endpoints.create_access_key) + list_access_keys = method(endpoints.list_access_keys) + revoke_access_key = method(endpoints.revoke_access_key) + + +class AccessKeysClient(_AccessKeyMethods, NemoClient): + """Sync client for the Scoped Access Key API.""" + + +class AsyncAccessKeysClient(_AccessKeyMethods, AsyncNemoClient): + """Async client for the Scoped Access Key API.""" + + +class AccessKeyIssuerClient(AccessKeyIssuer): + """AccessKeyIssuer implementation that calls the auth service over HTTP.""" + + def __init__(self, client: AccessKeysClient) -> None: + self._client = client + + def create(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: + try: + return self._client.create_access_key(body=request).data() + except NemoHTTPError as exc: + _raise_domain_error_from_http(exc) + raise + + def list(self) -> AccessKeyListResponse: + try: + return self._client.list_access_keys().data() + except NemoHTTPError as exc: + _raise_domain_error_from_http(exc) + raise + + def revoke(self, jti: str) -> None: + try: + self._client.revoke_access_key(jti=jti).data() + except NemoHTTPError as exc: + _raise_domain_error_from_http(exc) + raise + + +def _raise_domain_error_from_http(exc: NemoHTTPError) -> None: + if exc.status_code == 501: + raise AccessKeyOperationNotImplementedError(exc.detail) from exc + if exc.status_code == 404 and "not enabled" in exc.detail and "Scoped Access Keys" in exc.detail: + raise AccessKeyFeatureDisabledError(exc.detail) from exc diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/endpoints.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/endpoints.py new file mode 100644 index 0000000000..8b27e6547e --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/endpoints.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from abc import abstractmethod + +from nemo_platform_plugin.auth.access_keys.types import ( + AccessKeyCreateRequest, + AccessKeyCreateResponse, + AccessKeyListResponse, +) +from nemo_platform_plugin.client.endpoint import delete, get, post + + +@post("/apis/auth/v2/access-keys") +@abstractmethod +def create_access_key(*, body: AccessKeyCreateRequest) -> AccessKeyCreateResponse: ... + + +@get("/apis/auth/v2/access-keys") +@abstractmethod +def list_access_keys() -> AccessKeyListResponse: ... + + +@delete("/apis/auth/v2/access-keys/{jti}") +@abstractmethod +def revoke_access_key(*, jti: str) -> None: ... diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/issuer.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/issuer.py new file mode 100644 index 0000000000..c4482d1a83 --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/issuer.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Protocol + +from nemo_platform_plugin.auth.access_keys.types import ( + AccessKeyCreateRequest, + AccessKeyCreateResponse, + AccessKeyListResponse, +) + + +class AccessKeyOperationNotImplementedError(RuntimeError): + """Raised when a Scoped Access Key lifecycle operation is not implemented by the selected issuer.""" + + +class AccessKeyFeatureDisabledError(RuntimeError): + """Raised when Scoped Access Keys are disabled in platform config.""" + + +class AccessKeyIssuer(Protocol): + """Scoped Access Key implementation interface shared by service and client implementations.""" + + def create(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: ... + + def list(self) -> AccessKeyListResponse: ... + + def revoke(self, jti: str) -> None: ... diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/types.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/types.py new file mode 100644 index 0000000000..9fff86db73 --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/auth/access_keys/types.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class AccessKeyCreateRequest(BaseModel): + """Request body for creating a Scoped Access Key.""" + + name: str | None = Field( + default=None, + min_length=1, + max_length=128, + description="Optional human-readable Scoped Access Key label. The token jti remains the stable identifier.", + ) + expires_in_seconds: int | None = Field( + default=None, + ge=1, + description=( + "Scoped Access Key lifetime in seconds. Omit to use " + "auth.access_keys.default_expires_in_seconds. Send explicit null to request " + "a non-time-delimited key, which requires auth.access_keys.max_expires_in_seconds " + "to be disabled." + ), + ) + + +class AccessKeyMetadataResponse(BaseModel): + """Metadata for a Scoped Access Key.""" + + jti: str = Field(description="Stable JWT ID for this Scoped Access Key.") + name: str | None = Field(default=None, description="Optional human-readable Scoped Access Key label.") + principal: str = Field(description="Principal ID stamped into the token.") + created_at: datetime + expires_at: datetime | None = None + + +class AccessKeyCreateResponse(AccessKeyMetadataResponse): + """Create response. The token value is returned only once.""" + + token: str + token_type: Literal["Bearer"] + + +class AccessKeyListResponse(BaseModel): + """List response for Scoped Access Key metadata.""" + + data: list[AccessKeyMetadataResponse] + + +class AccessKeyAuthenticateResponse(BaseModel): + """Successful Scoped Access Key authentication response for gateway callouts.""" + + jti: str + principal: str + email: str | None = None + groups: list[str] = Field(default_factory=list) + scopes: list[str] = Field(default_factory=list) + + +class AccessKeyNotImplementedErrorResponse(BaseModel): + """Response returned by unsupported Scoped Access Key lifecycle endpoints.""" + + detail: str + + +class JsonWebKey(BaseModel): + """JSON Web Key object.""" + + model_config = ConfigDict(extra="allow") diff --git a/packages/nemo_platform_plugin/tests/auth/access_keys/test_client.py b/packages/nemo_platform_plugin/tests/auth/access_keys/test_client.py new file mode 100644 index 0000000000..4a45c29895 --- /dev/null +++ b/packages/nemo_platform_plugin/tests/auth/access_keys/test_client.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from datetime import UTC, datetime +from typing import cast +from unittest.mock import MagicMock + +import httpx +import pytest +from nemo_platform_plugin.auth.access_keys.client import AccessKeyIssuerClient, AccessKeysClient +from nemo_platform_plugin.auth.access_keys.issuer import ( + AccessKeyFeatureDisabledError, + AccessKeyOperationNotImplementedError, +) +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest, AccessKeyCreateResponse +from nemo_platform_plugin.client.errors import NemoHTTPError + + +class _AccessKeysClientStub: + def __init__(self) -> None: + self.create_access_key = MagicMock() + self.list_access_keys = MagicMock() + self.revoke_access_key = MagicMock() + + def as_client(self) -> AccessKeysClient: + return cast(AccessKeysClient, self) + + +def test_access_key_issuer_client_delegates_create_to_client() -> None: + created = AccessKeyCreateResponse( + jti="ak_example", + name=None, + token="signed.jwt.token", + token_type="Bearer", + principal="alice@example.com", + created_at=datetime(2026, 7, 28, 12, 0, tzinfo=UTC), + expires_at=None, + ) + client = _AccessKeysClientStub() + client.create_access_key.return_value.data.return_value = created + + issuer = AccessKeyIssuerClient(client.as_client()) + result = issuer.create(AccessKeyCreateRequest()) + + assert result == created + client.create_access_key.assert_called_once_with(body=AccessKeyCreateRequest()) + + +def test_access_key_issuer_client_revokes_by_jti() -> None: + client = _AccessKeysClientStub() + client.revoke_access_key.return_value.data.return_value = None + + issuer = AccessKeyIssuerClient(client.as_client()) + issuer.revoke("ak_example") + + client.revoke_access_key.assert_called_once_with(jti="ak_example") + + +def test_access_key_issuer_client_translates_http_501_to_domain_error() -> None: + response = httpx.Response( + 501, + json={"detail": "Scoped Access Key listing is not implemented."}, + request=httpx.Request("GET", "https://cluster.example.com/apis/auth/v2/access-keys"), + ) + client = _AccessKeysClientStub() + client.list_access_keys.side_effect = NemoHTTPError(response) + + issuer = AccessKeyIssuerClient(client.as_client()) + + with pytest.raises(AccessKeyOperationNotImplementedError, match="not implemented"): + issuer.list() + + +def test_access_key_issuer_client_translates_disabled_feature_to_domain_error() -> None: + response = httpx.Response( + 404, + json={"detail": "Scoped Access Keys are not enabled"}, + request=httpx.Request("POST", "https://cluster.example.com/apis/auth/v2/access-keys"), + ) + client = _AccessKeysClientStub() + client.create_access_key.side_effect = NemoHTTPError(response) + + issuer = AccessKeyIssuerClient(client.as_client()) + + with pytest.raises(AccessKeyFeatureDisabledError, match="not enabled"): + issuer.create(AccessKeyCreateRequest()) diff --git a/packages/nemo_platform_plugin/tests/auth/access_keys/test_endpoints.py b/packages/nemo_platform_plugin/tests/auth/access_keys/test_endpoints.py new file mode 100644 index 0000000000..4fc2425361 --- /dev/null +++ b/packages/nemo_platform_plugin/tests/auth/access_keys/test_endpoints.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from nemo_platform_plugin.auth.access_keys import endpoints +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest +from nemo_platform_plugin.client.types import PreparedRequest + + +def test_create_access_key_endpoint_uses_gateway_path() -> None: + prepared = endpoints.create_access_key(body=AccessKeyCreateRequest(name="gtc-intake")) + + assert isinstance(prepared, PreparedRequest) + assert prepared.method == "POST" + assert prepared.path_template == "/apis/auth/v2/access-keys" + assert prepared.content_type == "application/json" + + +def test_create_access_key_endpoint_allows_unnamed_tokens() -> None: + prepared = endpoints.create_access_key(body=AccessKeyCreateRequest()) + + assert prepared.method == "POST" + assert prepared.path_template == "/apis/auth/v2/access-keys" + assert prepared.content == b"{}" + + +def test_revoke_access_key_endpoint_uses_jti_path_param() -> None: + prepared = endpoints.revoke_access_key(jti="ak_example") + + assert prepared.method == "DELETE" + assert prepared.path_template == "/apis/auth/v2/access-keys/{jti}" + assert prepared.path_params == {"jti": "ak_example"} + + +def test_list_access_keys_endpoint_is_stable_for_future_persistence() -> None: + prepared = endpoints.list_access_keys() + + assert prepared.method == "GET" + assert prepared.path_template == "/apis/auth/v2/access-keys" diff --git a/packages/nemo_platform_plugin/tests/auth/access_keys/test_types.py b/packages/nemo_platform_plugin/tests/auth/access_keys/test_types.py new file mode 100644 index 0000000000..02172f5e00 --- /dev/null +++ b/packages/nemo_platform_plugin/tests/auth/access_keys/test_types.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest + + +def test_access_key_create_request_omits_unset_expiry_from_json() -> None: + request = AccessKeyCreateRequest(name="default-expiry") + + assert request.expires_in_seconds is None + assert "expires_in_seconds" not in request.model_fields_set + assert request.model_dump_json(exclude_unset=True) == '{"name":"default-expiry"}' + + +def test_access_key_create_request_preserves_explicit_null_expiry_in_json() -> None: + request = AccessKeyCreateRequest(name="unlimited", expires_in_seconds=None) + + assert request.expires_in_seconds is None + assert "expires_in_seconds" in request.model_fields_set + assert request.model_dump_json(exclude_unset=True) == '{"name":"unlimited","expires_in_seconds":null}' diff --git a/packages/nmp_common/src/nmp/common/auth/__init__.py b/packages/nmp_common/src/nmp/common/auth/__init__.py index 513eee08b0..f90a1da3da 100644 --- a/packages/nmp_common/src/nmp/common/auth/__init__.py +++ b/packages/nmp_common/src/nmp/common/auth/__init__.py @@ -5,6 +5,12 @@ from nmp.common.config import AuthConfig +from .access_keys import ( + ACCESS_KEY_JWKS_PATH, + ACCESS_KEY_TOKEN_TYPE, + AccessKeyIssuerService, + validate_access_key_token, +) from .client import AuthClient, AuthorizationResult from .dependencies import ( auth_as_service, @@ -32,6 +38,9 @@ "InvalidScopeFormatError", "AuthorizationMiddleware", "AuthorizationResult", + "ACCESS_KEY_JWKS_PATH", + "ACCESS_KEY_TOKEN_TYPE", + "AccessKeyIssuerService", "NMP_PRINCIPAL_ENVVAR", "Principal", "auth_as_service", @@ -41,4 +50,5 @@ "compute_accessible_workspaces", "get_auth_client", "get_principal_auth_headers", + "validate_access_key_token", ] diff --git a/packages/nmp_common/src/nmp/common/auth/access_keys.py b/packages/nmp_common/src/nmp/common/auth/access_keys.py new file mode 100644 index 0000000000..6e887794fd --- /dev/null +++ b/packages/nmp_common/src/nmp/common/auth/access_keys.py @@ -0,0 +1,367 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any, Callable + +import httpx +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa +from nemo_platform_plugin.auth.access_keys.issuer import ( + AccessKeyFeatureDisabledError, + AccessKeyIssuer, + AccessKeyOperationNotImplementedError, +) +from nemo_platform_plugin.auth.access_keys.types import ( + AccessKeyCreateRequest, + AccessKeyCreateResponse, + AccessKeyListResponse, +) +from nmp.common.config import AuthConfig, get_platform_config + +from .jwks import DEFAULT_JWKS_CACHE_LIFESPAN, AsyncJWKSClient, signing_jwk_from_jwks +from .jwt import TokenClaims +from .models import Principal +from .signing_keys import RSASigningKey, RSASigningKeyCache + +ACCESS_KEY_TOKEN_TYPE = "access_key" +ACCESS_KEY_JWKS_PATH = "/apis/auth/jwks" + + +def platform_token_issuer(config: AuthConfig) -> str: + if config.token_signing.issuer: + return config.token_signing.issuer.rstrip("/") + return f"{get_platform_config().base_url.rstrip('/')}/apis/auth" + + +def access_key_issuer(config: AuthConfig) -> str: + return platform_token_issuer(config) + + +def access_key_jwks_uri(config: AuthConfig) -> str: + return f"{get_platform_config().base_url.rstrip('/')}{ACCESS_KEY_JWKS_PATH}" + + +_ACCESS_KEY_SIGNING_KEY_CACHE = RSASigningKeyCache() +_ACCESS_KEY_MISSING_PRIVATE_KEY_MESSAGE = ( + "auth.token_signing.private_key_file must be configured to create Scoped Access Keys" +) +_ACCESS_KEY_INVALID_PRIVATE_KEY_MESSAGE = "auth.token_signing.private_key_file must contain an RSA private key" +_ACCESS_KEY_JWKS_CLIENTS: dict[str, AsyncJWKSClient] = {} +_EXPIRES_IN_SECONDS_FIELD = "expires_in_seconds" + + +@dataclass(frozen=True) +class _AccessKeyTokenPayload: + claims: dict[str, Any] + jti: str + name: str | None + principal: str + created_at: datetime + expires_at: datetime | None + + +def clear_access_key_signing_key_cache() -> None: + _ACCESS_KEY_SIGNING_KEY_CACHE.clear() + _ACCESS_KEY_JWKS_CLIENTS.clear() + + +def _groups_claim_for_gateway_header(groups: list[str]) -> str | None: + groups_claim = ",".join(group.strip() for group in groups if group.strip()) + return groups_claim or None + + +def _groups_from_claim(groups_claim: Any) -> list[str]: + if isinstance(groups_claim, str): + return [group.strip() for group in groups_claim.split(",") if group.strip()] + if isinstance(groups_claim, list): + return [group for group in groups_claim if isinstance(group, str)] + return [] + + +def _access_key_signing_key(config: AuthConfig) -> RSASigningKey: + return _ACCESS_KEY_SIGNING_KEY_CACHE.get_from_file( + kid=config.token_signing.key_id, + private_key_file=config.token_signing.private_key_file, + missing_private_key_message=_ACCESS_KEY_MISSING_PRIVATE_KEY_MESSAGE, + invalid_private_key_message=_ACCESS_KEY_INVALID_PRIVATE_KEY_MESSAGE, + ) + + +async def _access_key_signing_key_async(config: AuthConfig) -> RSASigningKey: + return await _ACCESS_KEY_SIGNING_KEY_CACHE.get_from_file_async( + kid=config.token_signing.key_id, + private_key_file=config.token_signing.private_key_file, + missing_private_key_message=_ACCESS_KEY_MISSING_PRIVATE_KEY_MESSAGE, + invalid_private_key_message=_ACCESS_KEY_INVALID_PRIVATE_KEY_MESSAGE, + ) + + +def _private_key(config: AuthConfig) -> rsa.RSAPrivateKey: + return _access_key_signing_key(config).private_key + + +def public_jwk_from_private_key_pem(config: AuthConfig) -> dict[str, Any]: + return dict(_access_key_signing_key(config).public_jwk) + + +async def public_jwk_from_private_key_pem_async(config: AuthConfig) -> dict[str, Any]: + signing_key = await _access_key_signing_key_async(config) + return dict(signing_key.public_jwk) + + +def _access_key_jwks_client(config: AuthConfig) -> AsyncJWKSClient: + jwks_uri = access_key_jwks_uri(config) + client = _ACCESS_KEY_JWKS_CLIENTS.get(jwks_uri) + if client is None: + client = AsyncJWKSClient(jwks_uri, lifespan=DEFAULT_JWKS_CACHE_LIFESPAN) + _ACCESS_KEY_JWKS_CLIENTS[jwks_uri] = client + return client + + +async def _access_key_signing_key_from_remote_jwks(config: AuthConfig, token: str) -> Any: + return (await _access_key_jwks_client(config).get_signing_key_from_jwt(token)).key + + +def _resolve_expires_in_seconds(config: AuthConfig, request: AccessKeyCreateRequest) -> int | None: + max_expires_in_seconds = config.access_keys.max_expires_in_seconds + expires_in_seconds_was_set = _EXPIRES_IN_SECONDS_FIELD in request.model_fields_set + if expires_in_seconds_was_set: + expires_in_seconds = request.expires_in_seconds + else: + expires_in_seconds = config.access_keys.default_expires_in_seconds + + if expires_in_seconds is None: + if max_expires_in_seconds is not None: + if expires_in_seconds_was_set: + raise RuntimeError( + "expires_in_seconds=null requires auth.access_keys.max_expires_in_seconds to be disabled" + ) + raise RuntimeError( + "expires_in_seconds is required when auth.access_keys.default_expires_in_seconds is null " + "and auth.access_keys.max_expires_in_seconds is finite" + ) + return None + + if max_expires_in_seconds is not None and expires_in_seconds > max_expires_in_seconds: + raise RuntimeError( + "expires_in_seconds must be less than or equal to " + f"auth.access_keys.max_expires_in_seconds ({max_expires_in_seconds})" + ) + return expires_in_seconds + + +class AccessKeyIssuerService(AccessKeyIssuer): + """AccessKeyIssuer implementation that signs Scoped Access Key JWTs in the auth service.""" + + def __init__( + self, + *, + config: AuthConfig, + principal: Principal, + now: Callable[[], int] | None = None, + ) -> None: + self._config = config + self._principal = principal + self._now = now or (lambda: int(time.time())) + + def _ensure_enabled(self) -> None: + if not self._config.access_keys.enabled: + raise AccessKeyFeatureDisabledError("Scoped Access Keys are not enabled") + + def create(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: + self._ensure_enabled() + expires_in_seconds = _resolve_expires_in_seconds(self._config, request) + return _create_access_key_token( + self._config, + principal=self._principal, + name=request.name, + expires_in_seconds=expires_in_seconds, + now=self._now(), + ) + + async def create_async(self, request: AccessKeyCreateRequest) -> AccessKeyCreateResponse: + self._ensure_enabled() + expires_in_seconds = _resolve_expires_in_seconds(self._config, request) + return await _create_access_key_token_async( + self._config, + principal=self._principal, + name=request.name, + expires_in_seconds=expires_in_seconds, + now=self._now(), + ) + + def list(self) -> AccessKeyListResponse: + self._ensure_enabled() + raise AccessKeyOperationNotImplementedError("Scoped Access Key listing is not implemented.") + + def revoke(self, jti: str) -> None: + self._ensure_enabled() + raise AccessKeyOperationNotImplementedError(f"Scoped Access Key revocation for {jti} is not implemented.") + + +def _create_access_key_token( + config: AuthConfig, + *, + principal: Principal, + name: str | None = None, + expires_in_seconds: int | None = None, + now: int, +) -> AccessKeyCreateResponse: + payload = _build_access_key_token_payload( + config, + principal=principal, + name=name, + expires_in_seconds=expires_in_seconds, + now=now, + ) + return _access_key_response_from_payload(payload, _access_key_signing_key(config)) + + +async def _create_access_key_token_async( + config: AuthConfig, + *, + principal: Principal, + name: str | None = None, + expires_in_seconds: int | None = None, + now: int, +) -> AccessKeyCreateResponse: + payload = _build_access_key_token_payload( + config, + principal=principal, + name=name, + expires_in_seconds=expires_in_seconds, + now=now, + ) + signing_key = await _access_key_signing_key_async(config) + return _access_key_response_from_payload(payload, signing_key) + + +def _build_access_key_token_payload( + config: AuthConfig, + *, + principal: Principal, + name: str | None, + expires_in_seconds: int | None, + now: int, +) -> _AccessKeyTokenPayload: + if not config.access_keys.enabled: + raise AccessKeyFeatureDisabledError("Scoped Access Keys are not enabled") + if principal.id.startswith("service:"): + raise RuntimeError("Scoped Access Keys cannot be created for service principals") + + issued_at = now + jti = f"ak_{uuid.uuid4().hex}" + access_key_metadata: dict[str, Any] = {"version": 1} + if name is not None: + access_key_metadata["name"] = name + claims: dict[str, Any] = { + "iss": access_key_issuer(config), + "aud": config.access_keys.audience, + "sub": principal.id, + "iat": issued_at, + "nbf": issued_at, + "jti": jti, + "nmp_token_type": ACCESS_KEY_TOKEN_TYPE, + "nmp_access_key": access_key_metadata, + } + if principal.email: + claims["email"] = principal.email + if principal.groups: + groups_claim = _groups_claim_for_gateway_header(principal.groups) + if groups_claim: + claims["groups"] = groups_claim + expires_at = None + if expires_in_seconds is not None: + expires_at_timestamp = issued_at + expires_in_seconds + claims["exp"] = expires_at_timestamp + expires_at = datetime.fromtimestamp(expires_at_timestamp, tz=UTC) + + return _AccessKeyTokenPayload( + claims=claims, + jti=jti, + name=name, + principal=principal.id, + created_at=datetime.fromtimestamp(issued_at, tz=UTC), + expires_at=expires_at, + ) + + +def _access_key_response_from_payload( + payload: _AccessKeyTokenPayload, + signing_key: RSASigningKey, +) -> AccessKeyCreateResponse: + token = jwt.encode( + payload.claims, + signing_key.private_key, + algorithm="RS256", + headers={"kid": signing_key.kid}, + ) + return AccessKeyCreateResponse( + jti=payload.jti, + name=payload.name, + token=token, + token_type="Bearer", + principal=payload.principal, + created_at=payload.created_at, + expires_at=payload.expires_at, + ) + + +async def validate_access_key_token( + config: AuthConfig, + token: str, + *, + jwks_override: dict[str, Any] | None = None, + now: int | None = None, +) -> TokenClaims | None: + if not config.access_keys.enabled: + return None + if "jwt" not in config.access_keys.accepted_formats: + return None + + try: + unverified = jwt.decode(token, options={"verify_signature": False}) + if unverified.get("nmp_token_type") != ACCESS_KEY_TOKEN_TYPE: + return None + + if jwks_override is None: + signing_key = await _access_key_signing_key_from_remote_jwks(config, token) + else: + signing_key = signing_jwk_from_jwks(token, jwks_override).key + + options: dict[str, Any] = {"require": ["sub", "iat", "nbf", "jti"]} + decode_kwargs: dict[str, Any] = { + "algorithms": ["RS256"], + "audience": config.access_keys.audience, + "issuer": access_key_issuer(config), + "options": options, + "leeway": 30, + } + if now is not None: + decode_kwargs["current_time"] = now + claims = jwt.decode(token, signing_key, **decode_kwargs) + + subject = claims.get("sub") + if not isinstance(subject, str) or not subject or subject.startswith("service:"): + return None + + groups = _groups_from_claim(claims.get("groups", [])) + scope_claim = claims.get("scope") or claims.get("scp") + scopes = scope_claim.split() if isinstance(scope_claim, str) else [] + return TokenClaims( + subject=subject, + email=claims.get("email") if isinstance(claims.get("email"), str) else None, + groups=[group for group in groups if isinstance(group, str)], + scopes=scopes, + raw_claims=claims, + ) + except httpx.HTTPError: + raise + except Exception: + return None diff --git a/packages/nmp_common/src/nmp/common/auth/bearer.py b/packages/nmp_common/src/nmp/common/auth/bearer.py new file mode 100644 index 0000000000..efebfaf138 --- /dev/null +++ b/packages/nmp_common/src/nmp/common/auth/bearer.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + + +class MalformedBearerTokenError(ValueError): + """Raised when an Authorization header uses Bearer but has invalid credentials.""" + + +def parse_bearer_authorization_header(auth_header: str | None) -> str | None: + """Return the bearer token, None for non-bearer auth, or raise for malformed bearer auth.""" + if auth_header is None: + return None + + parts = auth_header.strip().split() + if not parts: + return None + if parts[0].lower() != "bearer": + return None + if len(parts) != 2: + raise MalformedBearerTokenError("Bearer authorization must include exactly one token") + return parts[1] diff --git a/packages/nmp_common/src/nmp/common/auth/jwks.py b/packages/nmp_common/src/nmp/common/auth/jwks.py new file mode 100644 index 0000000000..b47c592f40 --- /dev/null +++ b/packages/nmp_common/src/nmp/common/auth/jwks.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Async JWKS fetching and signing-key resolution for bearer-token validation.""" + +from __future__ import annotations + +import time +from typing import Any + +import jwt +from nmp.common import http_clients + +from .loading_cache import AsyncCoalescingLoader + +DEFAULT_JWKS_CACHE_LIFESPAN = 3600 +UNKNOWN_KID_REFRESH_MIN_INTERVAL_SECONDS = 5.0 + + +class _UnknownJWKKeyIDError(jwt.InvalidTokenError): + def __init__(self, key_id: str) -> None: + self.key_id = key_id + super().__init__(f'Unable to find a signing key that matches: "{key_id}"') + + +def validate_jwks(jwks: dict[str, Any]) -> None: + """Raise when a JWKS document cannot be parsed by PyJWT.""" + jwt.PyJWKSet.from_dict(jwks) + + +def signing_jwk_from_jwks(token: str, jwks: dict[str, Any]) -> Any: + """Resolve the JWK matching the token's ``kid`` from a JWKS document.""" + key_id = jwt.get_unverified_header(token).get("kid") + if not isinstance(key_id, str) or not key_id: + raise jwt.InvalidTokenError("JWT did not include a signing key id") + + jwk_set = jwt.PyJWKSet.from_dict(jwks) + for jwk in jwk_set.keys: + if jwk.public_key_use in ("sig", None) and jwk.key_id == key_id: + return jwk + raise _UnknownJWKKeyIDError(key_id) + + +# Keep JWKS retrieval async. PyJWT's PyJWKClient performs synchronous network I/O +# and can block async request handlers. +class AsyncJWKSClient: + """Async JWKS client with TTL caching and one refresh on unknown key IDs.""" + + def __init__(self, jwks_uri: str, *, lifespan: int = DEFAULT_JWKS_CACHE_LIFESPAN) -> None: + self._jwks_uri = jwks_uri + self._lifespan = lifespan + self._jwks: dict[str, Any] | None = None + self._jwks_cache_time = 0.0 + self._unknown_kid_refresh_loader: AsyncCoalescingLoader[dict[str, Any]] = AsyncCoalescingLoader( + min_interval_seconds=UNKNOWN_KID_REFRESH_MIN_INTERVAL_SECONDS + ) + + async def get_signing_key_from_jwt(self, token: str) -> Any: + jwks, cache_hit = await self._fetch_jwks() + try: + return signing_jwk_from_jwks(token, jwks) + except _UnknownJWKKeyIDError: + if not cache_hit: + raise + refreshed_jwks = await self._refresh_jwks_for_unknown_kid() + return signing_jwk_from_jwks(token, refreshed_jwks) + + def clear_cache(self) -> None: + self._jwks = None + self._jwks_cache_time = 0.0 + self._unknown_kid_refresh_loader = AsyncCoalescingLoader( + min_interval_seconds=UNKNOWN_KID_REFRESH_MIN_INTERVAL_SECONDS + ) + + async def _refresh_jwks_for_unknown_kid(self) -> dict[str, Any]: + return await self._unknown_kid_refresh_loader.load( + self._force_refresh_jwks, + rate_limited_value=self._cached_jwks, + ) + + def _cached_jwks(self) -> dict[str, Any]: + if self._jwks is None: + raise jwt.InvalidTokenError("JWKS cache is empty") + return self._jwks + + async def _force_refresh_jwks(self) -> dict[str, Any]: + jwks, _ = await self._fetch_jwks(refresh=True) + return jwks + + async def _fetch_jwks(self, *, refresh: bool = False) -> tuple[dict[str, Any], bool]: + now = time.monotonic() + if self._jwks is not None and not refresh and self._lifespan > 0: + if now - self._jwks_cache_time < self._lifespan: + return self._jwks, True + + response = await http_clients.shared_async_http_client().get(self._jwks_uri, timeout=10.0) + response.raise_for_status() + jwks = response.json() + if not isinstance(jwks, dict): + raise jwt.InvalidTokenError("JWKS response was not an object") + validate_jwks(jwks) + if self._lifespan > 0: + self._jwks = jwks + self._jwks_cache_time = now + return jwks, False diff --git a/packages/nmp_common/src/nmp/common/auth/jwt.py b/packages/nmp_common/src/nmp/common/auth/jwt.py index f68534abac..4f13b95cd7 100644 --- a/packages/nmp_common/src/nmp/common/auth/jwt.py +++ b/packages/nmp_common/src/nmp/common/auth/jwt.py @@ -10,9 +10,11 @@ import httpx import jwt -from jwt import PyJWKClient +from jwt.types import Options from nmp.common.config import AuthConfig +from .jwks import AsyncJWKSClient + logger = logging.getLogger(__name__) # Cache TTLs for JWTValidator internals. @@ -45,7 +47,7 @@ class JWTValidator: def __init__(self, config: AuthConfig): self.config = config - self._jwks_client: Optional[PyJWKClient] = None + self._jwks_client: Optional[AsyncJWKSClient] = None self._discovery_cache: Optional[dict] = None self._discovery_cache_time: float = 0.0 @@ -68,7 +70,7 @@ async def _discover_oidc_config(self) -> dict: self._discovery_cache_time = now return self._discovery_cache - async def _get_jwks_client(self) -> PyJWKClient: + async def _get_jwks_client(self) -> AsyncJWKSClient: """Get or create JWKS client for token validation. The client is initialized with a lifespan so that cached keys @@ -84,7 +86,7 @@ async def _get_jwks_client(self) -> PyJWKClient: discovery = await self._discover_oidc_config() jwks_uri = discovery["jwks_uri"] - self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True, lifespan=_JWKS_CACHE_LIFESPAN) + self._jwks_client = AsyncJWKSClient(jwks_uri, lifespan=_JWKS_CACHE_LIFESPAN) return self._jwks_client def _extract_token_claims(self, claims: dict) -> Optional[TokenClaims]: @@ -170,7 +172,7 @@ async def validate_token(self, token: str) -> Optional[TokenClaims]: return self._extract_token_claims(claims) jwks_client = await self._get_jwks_client() - signing_key = jwks_client.get_signing_key_from_jwt(token) + signing_key = await jwks_client.get_signing_key_from_jwt(token) # Only validate audience when explicitly configured. # When audience is not set, skip the check so tokens from any @@ -182,7 +184,7 @@ async def validate_token(self, token: str) -> Optional[TokenClaims]: allowed_issuers = [self.config.oidc.issuer] + self.config.oidc.additional_issuers # Decode and validate token (validate issuer manually to support multiple) - decode_options: dict = {"require": ["exp", "iat", "sub"]} + decode_options: Options = {"require": ["exp", "iat", "sub"]} if audience is None: decode_options["verify_aud"] = False claims = jwt.decode( diff --git a/packages/nmp_common/src/nmp/common/auth/loading_cache.py b/packages/nmp_common/src/nmp/common/auth/loading_cache.py new file mode 100644 index 0000000000..a208013131 --- /dev/null +++ b/packages/nmp_common/src/nmp/common/auth/loading_cache.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Awaitable, Callable, Coroutine, Hashable +from typing import Any, Generic, TypeVar, cast + +KeyT = TypeVar("KeyT", bound=Hashable) +ValueT = TypeVar("ValueT") + +_MISSING = object() + + +class AsyncLoadingCache(Generic[KeyT, ValueT]): + """Async cache that guards access and serializes cache misses.""" + + def __init__(self) -> None: + self._values: dict[KeyT, ValueT] = {} + self._lock = asyncio.Lock() + + async def clear(self) -> None: + async with self._lock: + self._values.clear() + + async def get_or_load(self, key: KeyT, loader: Callable[[], Awaitable[ValueT]]) -> ValueT: + async with self._lock: + cached = self._values.get(key, _MISSING) + if cached is not _MISSING: + return cast(ValueT, cached) + + value = await loader() + self._values[key] = value + return value + + +class AsyncCoalescingLoader(Generic[ValueT]): + """Share one in-flight async load among concurrent callers.""" + + def __init__(self, *, min_interval_seconds: float = 0.0) -> None: + self._min_interval_seconds = min_interval_seconds + self._last_load_time = 0.0 + self._task: asyncio.Task[ValueT] | None = None + self._lock = asyncio.Lock() + + async def clear(self) -> None: + async with self._lock: + self._last_load_time = 0.0 + self._task = None + + async def load( + self, + loader: Callable[[], Coroutine[Any, Any, ValueT]], + *, + rate_limited_value: Callable[[], ValueT] | None = None, + ) -> ValueT: + async with self._lock: + task = self._task + if task is None: + now = time.monotonic() + if ( + rate_limited_value is not None + and self._min_interval_seconds > 0 + and self._last_load_time > 0 + and now - self._last_load_time < self._min_interval_seconds + ): + return rate_limited_value() + + self._last_load_time = now + task = asyncio.create_task(loader()) + task.add_done_callback(self._schedule_forget_task) + self._task = task + + return await asyncio.shield(task) + + def _schedule_forget_task(self, task: asyncio.Task[ValueT]) -> None: + asyncio.create_task(self._forget_task(task)) + + async def _forget_task(self, task: asyncio.Task[ValueT]) -> None: + async with self._lock: + if self._task is task: + self._task = None + if not task.cancelled(): + task.exception() diff --git a/packages/nmp_common/src/nmp/common/auth/middleware.py b/packages/nmp_common/src/nmp/common/auth/middleware.py index 348b6477b8..f8b63a13a1 100644 --- a/packages/nmp_common/src/nmp/common/auth/middleware.py +++ b/packages/nmp_common/src/nmp/common/auth/middleware.py @@ -15,10 +15,12 @@ from starlette.responses import JSONResponse from starlette.types import ASGIApp +from .bearer import MalformedBearerTokenError, parse_bearer_authorization_header from .client import AuthClient from .dependencies import auth_client_context from .exceptions import InvalidPrincipalHeader, InvalidScopeFormatError from .models import Principal +from .token_resolver import ResolvedBearerToken, resolve_bearer_token logger = logging.getLogger(__name__) @@ -70,7 +72,8 @@ def _embedded_pdp_base_url_hint(config: AuthConfig) -> str: "/health/ready", "/metrics", "/apis/auth/discovery", # Discovery endpoint for CLI/SDK - "/apis/auth/jwks", # Workload identity exchange signing keys + "/apis/auth/authenticate", # Bearer-token validation callout + "/apis/auth/jwks", # NeMo-minted bearer-token signing keys "/apis/auth/token", # Workload identity token exchange validates the subject token itself } @@ -81,6 +84,7 @@ def _embedded_pdp_base_url_hint(config: AuthConfig) -> str: # Path prefixes that bypass authorization BYPASS_PREFIXES = ( + "/apis/auth/authenticate/", # Envoy ext_authz path_prefix callout includes the original protected path "/studio", # Studio UI static files — the SPA handles its own OIDC login ) @@ -197,6 +201,96 @@ async def _call_next_with_auth_client( finally: auth_client_context.reset(context_token) + async def _call_pdp( + self, + auth_client: AuthClient, + request: Request, + scopes: list[str] | None, + *, + anonymous_denial_is_401: bool, + ) -> Response | None: + """Call PDP and return an error response, or None when authorized.""" + try: + result = await auth_client.authorize_request( + method=request.method, + path=request.url.path, + scopes=scopes, + http_client=auth_client.http_client, + ) + except httpx.ConnectError as e: + logger.error( + "Cannot connect to PDP at %s: %s (service: %s)%s", + self.config.auth_url, + _describe_pdp_failure(e), + self.service_name or "unknown", + _embedded_pdp_base_url_hint(self.config), + ) + return JSONResponse( + status_code=503, + content={"detail": "Authorization service unavailable"}, + ) + except httpx.TimeoutException as e: + logger.error( + "PDP timeout at %s: %s (service: %s)", + self.config.auth_url, + _describe_pdp_failure(e), + self.service_name or "unknown", + ) + return JSONResponse( + status_code=504, + content={"detail": "Authorization service timeout"}, + ) + except httpx.HTTPStatusError as e: + logger.error( + "PDP error response from %s: HTTP %s (service: %s) body=%r", + self.config.auth_url, + e.response.status_code, + self.service_name or "unknown", + (e.response.text or "")[:500], + ) + return JSONResponse( + status_code=502, + content={"detail": "Authorization service error"}, + ) + except InvalidScopeFormatError as e: + logger.warning( + "Invalid OAuth scope format (service: %s): %s", + self.service_name or "unknown", + str(e), + ) + return JSONResponse( + status_code=400, + content={"detail": str(e)}, + ) + except Exception as e: + logger.exception( + "Unexpected error during authorization (service: %s): %s", + self.service_name or "unknown", + _describe_pdp_failure(e), + ) + return JSONResponse( + status_code=500, + content={"detail": "Internal authorization error"}, + ) + + if result.allowed: + return None + + principal_id = auth_client.principal.id + status_code = 401 if anonymous_denial_is_401 and not principal_id else 403 + logger.warning( + "Authorization denied for %s %s (principal: %s, service: %s, reason: %s)", + request.method, + request.url.path, + principal_id or "anonymous", + self.service_name or "unknown", + result.reason, + ) + return JSONResponse( + status_code=status_code, + content={"detail": "Unauthorized" if status_code == 401 else "Forbidden"}, + ) + async def dispatch(self, request: Request, call_next: Callable) -> Response: """Main entry point - routes requests through the appropriate authorization flow. @@ -258,15 +352,12 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: return await self._handle_auth_disabled_request(request, call_next) # Try to extract principal from Authorization: Bearer header (native OIDC or unsigned JWT) - auth_header = headers_dict.get("authorization", "") - if auth_header.lower().startswith("bearer "): - if self.config.oidc.enabled or self.config.allow_unsigned_jwt: - return await self._handle_bearer_token_request(request, call_next, auth_header) - logger.warning("Bearer token provided but OIDC is not configured") - return JSONResponse( - status_code=401, - content={"detail": "Bearer token authentication not configured"}, - ) + try: + bearer_token = parse_bearer_authorization_header(headers_dict.get("authorization")) + except MalformedBearerTokenError: + return JSONResponse(status_code=401, content={"detail": "Invalid bearer token"}) + if bearer_token is not None: + return await self._handle_bearer_token_request(request, call_next, bearer_token) # Perform authorization check with auth endpoint (allows PDP to decide for anonymous access) return await self._handle_auth_check(request, call_next) @@ -289,12 +380,13 @@ async def _handle_hf_compatible_request( Returns: Response from downstream handlers, or 401 if not a valid service principal """ - auth_header = headers_dict.get("authorization", "") - if auth_header.lower().startswith("bearer "): - token = auth_header[7:] - if token.startswith("service:"): - headers_dict["x-nmp-principal-id"] = token - return await self._handle_principal_headers_request(request, call_next, headers_dict) + try: + bearer_token = parse_bearer_authorization_header(headers_dict.get("authorization")) + except MalformedBearerTokenError: + return JSONResponse(status_code=401, content={"detail": "Unauthorized"}) + if bearer_token is not None and bearer_token.startswith("service:"): + headers_dict["x-nmp-principal-id"] = bearer_token + return await self._handle_principal_headers_request(request, call_next, headers_dict) return JSONResponse(status_code=401, content={"detail": "Unauthorized"}) @@ -363,55 +455,42 @@ async def _handle_principal_headers_request( # flow synthesizes these from its Bearer service token without mutating request.headers. return await self._handle_auth_check(request, call_next, headers_dict) - async def _handle_bearer_token_request(self, request: Request, call_next: Callable, auth_header: str) -> Response: - """Handle requests with Authorization: Bearer tokens. - - Validates the JWT token directly against the configured OIDC issuer, - extracts principal info, and proceeds with authorization. - - Args: - request: The incoming HTTP request - call_next: The next middleware/handler in the chain - auth_header: The Authorization header value - - Returns: - The response from downstream handlers or an error response - """ + async def _handle_bearer_token_request(self, request: Request, call_next: Callable, token: str) -> Response: + """Handle requests with Authorization: Bearer tokens through the shared resolver.""" jwt_validator = self._get_jwt_validator() - - if jwt_validator is None: - logger.warning("Bearer token provided but OIDC is not configured") + if jwt_validator is None and not self.config.access_keys.enabled: + logger.warning("Bearer token provided but bearer token authentication is not configured") return JSONResponse( status_code=401, content={"detail": "Bearer token authentication not configured"}, ) - # Extract token from header - token = auth_header[7:] # Remove "Bearer " prefix - - # Validate token from .jwt import UnsignedJWTRejectedError try: - claims = await jwt_validator.validate_token(token) + resolved = await resolve_bearer_token(self.config, token, jwt_validator=jwt_validator) except UnsignedJWTRejectedError as exc: return JSONResponse( status_code=401, content={"detail": str(exc)}, ) - if claims is None: + if resolved is None: return JSONResponse( status_code=401, content={"detail": "Invalid or expired token"}, ) - # Create Principal from token claims - principal = Principal( - id=claims.subject, - email=claims.email, - groups=claims.groups, - ) + return await self._handle_resolved_bearer_token(request, call_next, resolved) + + async def _handle_resolved_bearer_token( + self, + request: Request, + call_next: Callable, + resolved: ResolvedBearerToken, + ) -> Response: + """Authorize a request after a bearer token has produced trusted claims.""" + principal = resolved.principal # Update the observability context with principal info for logging self._update_auth_context(principal) @@ -439,76 +518,15 @@ async def _handle_bearer_token_request(self, request: Request, call_next: Callab ) # Extract scopes from token claims - scopes = claims.scopes if claims.scopes else None - - try: - result = await auth_client.authorize_request( - method=request.method, - path=request.url.path, - scopes=scopes, - http_client=auth_client.http_client, - ) - except httpx.ConnectError as e: - logger.error( - "Cannot connect to PDP at %s: %s (service: %s)%s", - self.config.auth_url, - _describe_pdp_failure(e), - self.service_name or "unknown", - _embedded_pdp_base_url_hint(self.config), - ) - return JSONResponse( - status_code=503, - content={"detail": "Authorization service unavailable"}, - ) - except httpx.TimeoutException as e: - logger.error( - "PDP timeout at %s: %s (service: %s)", - self.config.auth_url, - _describe_pdp_failure(e), - self.service_name or "unknown", - ) - return JSONResponse( - status_code=504, - content={"detail": "Authorization service timeout"}, - ) - except httpx.HTTPStatusError as e: - logger.error( - "PDP error response from %s: HTTP %s (service: %s) body=%r", - self.config.auth_url, - e.response.status_code, - self.service_name or "unknown", - (e.response.text or "")[:500], - ) - return JSONResponse( - status_code=502, - content={"detail": "Authorization service error"}, - ) - except InvalidScopeFormatError as e: - logger.warning( - "Invalid OAuth scope format (service: %s): %s", - self.service_name or "unknown", - str(e), - ) - return JSONResponse( - status_code=400, - content={"detail": str(e)}, - ) - except Exception as e: - logger.exception( - "Unexpected error during authorization (service: %s): %s", - self.service_name or "unknown", - _describe_pdp_failure(e), - ) - return JSONResponse( - status_code=500, - content={"detail": "Internal authorization error"}, - ) - - if not result.allowed: - return JSONResponse( - status_code=403, - content={"detail": "Forbidden"}, - ) + scopes = resolved.scopes if resolved.scopes else None + + if error_response := await self._call_pdp( + auth_client, + request, + scopes, + anonymous_denial_is_401=False, + ): + return error_response return await self._call_next_with_auth_client(request, call_next, auth_client) @@ -600,84 +618,13 @@ async def _handle_auth_check( # Perform authorization check - only catch errors from the PDP call itself. # Errors from downstream handlers (call_next) should propagate normally. - try: - result = await auth_client.authorize_request( - method=request.method, - path=request.url.path, - scopes=scopes, - http_client=auth_client.http_client, - ) - except httpx.ConnectError as e: - logger.error( - "Cannot connect to PDP at %s: %s (service: %s)%s", - self.config.auth_url, - _describe_pdp_failure(e), - self.service_name or "unknown", - _embedded_pdp_base_url_hint(self.config), - ) - return JSONResponse( - status_code=503, - content={"detail": "Authorization service unavailable"}, - ) - except httpx.TimeoutException as e: - logger.error( - "PDP timeout at %s: %s (service: %s)", - self.config.auth_url, - _describe_pdp_failure(e), - self.service_name or "unknown", - ) - return JSONResponse( - status_code=504, - content={"detail": "Authorization service timeout"}, - ) - except httpx.HTTPStatusError as e: - logger.error( - "PDP error response from %s: HTTP %s (service: %s) body=%r", - self.config.auth_url, - e.response.status_code, - self.service_name or "unknown", - (e.response.text or "")[:500], - ) - return JSONResponse( - status_code=502, - content={"detail": "Authorization service error"}, - ) - except InvalidScopeFormatError as e: - logger.warning( - "Invalid OAuth scope format (service: %s): %s", - self.service_name or "unknown", - str(e), - ) - return JSONResponse( - status_code=400, - content={"detail": str(e)}, - ) - except Exception as e: - logger.exception( - "Unexpected error during authorization (service: %s): %s", - self.service_name or "unknown", - _describe_pdp_failure(e), - ) - return JSONResponse( - status_code=500, - content={"detail": "Internal authorization error"}, - ) - - # Check authorization result - if not result.allowed: - status_code = 401 if not principal.id else 403 - logger.warning( - "Authorization denied for %s %s (principal: %s, service: %s, reason: %s)", - request.method, - request.url.path, - principal.id or "anonymous", - self.service_name or "unknown", - result.reason, - ) - return JSONResponse( - status_code=status_code, - content={"detail": "Unauthorized" if status_code == 401 else "Forbidden"}, - ) + if error_response := await self._call_pdp( + auth_client, + request, + scopes, + anonymous_denial_is_401=True, + ): + return error_response # Authorization successful - set up context for downstream handlers. # This is outside the try/except so endpoint errors propagate normally diff --git a/packages/nmp_common/src/nmp/common/auth/signing_keys.py b/packages/nmp_common/src/nmp/common/auth/signing_keys.py new file mode 100644 index 0000000000..d5e7e10eec --- /dev/null +++ b/packages/nmp_common/src/nmp/common/auth/signing_keys.py @@ -0,0 +1,193 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from pathlib import Path +from typing import Any, TypeVar + +import aiofiles +import aiofiles.os +from cryptography.exceptions import UnsupportedAlgorithm +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.algorithms import RSAAlgorithm + +from .loading_cache import AsyncLoadingCache + +ValueT = TypeVar("ValueT") + + +@dataclass(frozen=True) +class RSASigningKey: + """Parsed RSA signing key material plus its public JWKS representation.""" + + private_key: rsa.RSAPrivateKey + public_key: rsa.RSAPublicKey + kid: str + public_jwk: dict[str, Any] + + +@dataclass(frozen=True) +class _FileCacheKey: + kid: str + private_key_file: str + mtime_ns: int + size: int + + +class RSASigningKeyCache: + """Cache RSA signing keys loaded from PEM files.""" + + def __init__(self) -> None: + self._cache: AsyncLoadingCache[_FileCacheKey, RSASigningKey] = AsyncLoadingCache() + + def clear(self) -> None: + _run_async(self.clear_async) + + async def clear_async(self) -> None: + await self._cache.clear() + + def get_from_file( + self, + *, + kid: str, + private_key_file: str | None, + missing_private_key_message: str, + invalid_private_key_message: str, + ) -> RSASigningKey: + return _run_async( + lambda: self.get_from_file_async( + kid=kid, + private_key_file=private_key_file, + missing_private_key_message=missing_private_key_message, + invalid_private_key_message=invalid_private_key_message, + ), + ) + + async def get_from_file_async( + self, + *, + kid: str, + private_key_file: str | None, + missing_private_key_message: str, + invalid_private_key_message: str, + ) -> RSASigningKey: + if not kid: + raise RuntimeError("token signing key id must be configured") + if not private_key_file: + raise RuntimeError(missing_private_key_message) + + path = Path(private_key_file) + try: + cache_key = await _file_cache_key_async(kid, path) + except OSError as exc: + raise RuntimeError(missing_private_key_message) from exc + return await self._cache.get_or_load( + cache_key, + lambda: _load_rsa_signing_key_async( + kid=kid, + path=path, + missing_private_key_message=missing_private_key_message, + invalid_private_key_message=invalid_private_key_message, + ), + ) + + def public_jwk_from_file( + self, + *, + kid: str, + private_key_file: str | None, + missing_private_key_message: str, + invalid_private_key_message: str, + ) -> dict[str, Any]: + return _run_async( + lambda: self.public_jwk_from_file_async( + kid=kid, + private_key_file=private_key_file, + missing_private_key_message=missing_private_key_message, + invalid_private_key_message=invalid_private_key_message, + ) + ) + + async def public_jwk_from_file_async( + self, + *, + kid: str, + private_key_file: str | None, + missing_private_key_message: str, + invalid_private_key_message: str, + ) -> dict[str, Any]: + signing_key = await self.get_from_file_async( + kid=kid, + private_key_file=private_key_file, + missing_private_key_message=missing_private_key_message, + invalid_private_key_message=invalid_private_key_message, + ) + return dict(signing_key.public_jwk) + + +def _run_async(factory: Callable[[], Coroutine[Any, Any, ValueT]]) -> ValueT: + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(factory()) + raise RuntimeError("Use RSASigningKeyCache async methods from async contexts") + + +async def _load_rsa_signing_key_async( + *, + kid: str, + path: Path, + missing_private_key_message: str, + invalid_private_key_message: str, +) -> RSASigningKey: + try: + async with aiofiles.open(path, "rb") as key_file: + private_key_pem = await key_file.read() + except OSError as exc: + raise RuntimeError(missing_private_key_message) from exc + + return _rsa_signing_key_from_pem( + kid=kid, + private_key_pem=private_key_pem, + invalid_private_key_message=invalid_private_key_message, + ) + + +async def _file_cache_key_async(kid: str, path: Path) -> _FileCacheKey: + stat = await aiofiles.os.stat(path) + return _FileCacheKey( + kid=kid, + private_key_file=str(path.expanduser().resolve(strict=False)), + mtime_ns=stat.st_mtime_ns, + size=stat.st_size, + ) + + +def _rsa_signing_key_from_pem( + *, + kid: str, + private_key_pem: bytes, + invalid_private_key_message: str, +) -> RSASigningKey: + try: + private_key = serialization.load_pem_private_key(private_key_pem, password=None) + except (TypeError, ValueError, UnsupportedAlgorithm) as exc: + raise RuntimeError(invalid_private_key_message) from exc + if not isinstance(private_key, rsa.RSAPrivateKey): + raise RuntimeError(invalid_private_key_message) + + public_key = private_key.public_key() + jwk = json.loads(RSAAlgorithm.to_jwk(public_key)) + jwk.update({"kid": kid, "use": "sig", "alg": "RS256"}) + return RSASigningKey( + private_key=private_key, + public_key=public_key, + kid=kid, + public_jwk=jwk, + ) diff --git a/packages/nmp_common/src/nmp/common/auth/token_resolver.py b/packages/nmp_common/src/nmp/common/auth/token_resolver.py new file mode 100644 index 0000000000..1dffa0b21b --- /dev/null +++ b/packages/nmp_common/src/nmp/common/auth/token_resolver.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from typing import Literal + +from nmp.common.config import AuthConfig + +from .jwt import JWTValidator, TokenClaims +from .models import Principal + +ResolvedTokenKind = Literal["access_key", "oidc_access_token", "workload_access_token", "workload_subject_token"] + + +@dataclass(frozen=True) +class ResolvedBearerToken: + claims: TokenClaims + token_kind: ResolvedTokenKind + + @property + def principal(self) -> Principal: + return Principal( + id=self.claims.subject, + email=self.claims.email, + groups=self.claims.groups, + ) + + @property + def scopes(self) -> list[str]: + return self.claims.scopes + + def principal_headers(self) -> dict[str, str]: + headers = self.principal.get_headers() + if self.scopes: + headers["X-NMP-Scopes"] = " ".join(self.scopes) + return headers + + +ExtraBearerTokenResolver = Callable[[str], Awaitable[ResolvedBearerToken | None]] + + +async def resolve_bearer_token( + config: AuthConfig, + token: str, + *, + jwt_validator: JWTValidator | None = None, + extra_resolvers: Sequence[ExtraBearerTokenResolver] = (), +) -> ResolvedBearerToken | None: + if config.access_keys.enabled: + from .access_keys import validate_access_key_token + + access_key_claims = await validate_access_key_token(config, token) + if access_key_claims is not None: + return ResolvedBearerToken(claims=access_key_claims, token_kind="access_key") + + for extra_resolver in extra_resolvers: + resolved = await extra_resolver(token) + if resolved is not None: + return resolved + + if not config.oidc.enabled and not config.allow_unsigned_jwt: + return None + + validator = jwt_validator or JWTValidator(config) + oidc_claims = await validator.validate_token(token) + if oidc_claims is None: + return None + return ResolvedBearerToken(claims=oidc_claims, token_kind="oidc_access_token") diff --git a/packages/nmp_common/src/nmp/common/config/base.py b/packages/nmp_common/src/nmp/common/config/base.py index badb052cf1..b11ca561ab 100644 --- a/packages/nmp_common/src/nmp/common/config/base.py +++ b/packages/nmp_common/src/nmp/common/config/base.py @@ -10,7 +10,7 @@ from __future__ import annotations -from typing import Literal +from typing import Annotated, Any, Literal, Self from nemo_platform_plugin.config import LOOPBACK_ADDRESSES as LOOPBACK_ADDRESSES from nemo_platform_plugin.config import NMP_CONFIG_FILE_PATH_DEFAULT as NMP_CONFIG_FILE_PATH_DEFAULT @@ -35,8 +35,8 @@ from nemo_platform_plugin.config import internal_field as internal_field from nemo_platform_plugin.config import register_platform_config_class as register_platform_config_class from nmp.common.config.paths import nmp_user_data_dir -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field, field_validator, model_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from sqlalchemy.engine import URL # Kept here for backward compat (used by services and tests) @@ -184,9 +184,12 @@ class OIDCConfig(BaseSettings): description="Lifetime in seconds for workload identity access tokens minted by the NeMo auth service.", ) - workload_token_key_id: str = Field( - default="nemo-workload-exchange", - description="JWT key id advertised by the NeMo auth service workload identity JWKS endpoint.", + workload_token_key_id: str | None = Field( + default=None, + description=( + "Workload-specific JWT key id advertised by the NeMo auth service workload identity JWKS endpoint. " + "When unset, workload token exchange uses auth.token_signing.key_id." + ), ) workload_token_private_key_file: str | None = Field( @@ -251,6 +254,105 @@ class OIDCConfig(BaseSettings): ) +class TokenSigningConfig(BaseSettings): + """Shared NeMo auth-service token signing configuration.""" + + issuer: str | None = Field( + default=None, + description="Shared issuer for NeMo Platform-minted JWTs. Defaults to /apis/auth.", + ) + key_id: str = Field( + default="nemo-platform-signing", + description="Shared JWT key id advertised by NeMo Platform JWKS endpoints.", + ) + private_key_file: str | None = Field( + default=None, + description=( + "Path to the PEM-encoded RSA private key used by the auth service to sign NeMo Platform-minted JWTs. " + "Intended for mounted shared secrets." + ), + ) + + +def _default_access_key_accepted_formats() -> list[Literal["jwt"]]: + return ["jwt"] + + +AccessKeyAcceptedFormat = Literal["jwt"] + + +class AccessKeyConfig(BaseSettings): + """NeMo Platform Scoped Access Key configuration.""" + + enabled: bool = Field( + default=False, + description="Enable NeMo Platform Scoped Access Key creation and validation.", + ) + issue_format: Literal["jwt"] = Field( + default="jwt", + description="Token format to issue for newly created Scoped Access Keys.", + ) + accepted_formats: Annotated[list[AccessKeyAcceptedFormat], NoDecode] = Field( + default_factory=_default_access_key_accepted_formats, + description="Scoped Access Key token formats accepted by validators.", + ) + audience: str = Field( + default="nemo-platform-access-key", + description="Expected audience for NeMo Platform Scoped Access Key JWTs.", + ) + default_expires_in_seconds: int | None = Field( + default=30 * 24 * 60 * 60, + ge=1, + description=( + "Default finite lifetime in seconds for newly created Scoped Access Keys when " + "expires_in_seconds is omitted. Set to null to require callers to provide an expiry " + "when max_expires_in_seconds is finite, or to make omitted expiry non-time-delimited " + "when max_expires_in_seconds is also null." + ), + ) + max_expires_in_seconds: int | None = Field( + default=30 * 24 * 60 * 60, + ge=1, + description=( + "Maximum finite lifetime accepted when creating Scoped Access Keys. " + "Set to null to allow explicit no-expiration requests." + ), + ) + + @staticmethod + def _parse_nullable_expiry(value: Any) -> Any: + if isinstance(value, str) and value.strip().lower() in {"", "none", "null"}: + return None + return value + + @field_validator("accepted_formats", mode="before") + @classmethod + def parse_accepted_formats(cls, value: Any) -> Any: + if not isinstance(value, str): + return value + + return [part.strip() for part in value.split(",") if part.strip()] + + @field_validator("default_expires_in_seconds", "max_expires_in_seconds", mode="before") + @classmethod + def parse_nullable_expiry(cls, value: Any) -> Any: + return cls._parse_nullable_expiry(value) + + @model_validator(mode="after") + def validate_expiry_policy(self) -> Self: + if self.max_expires_in_seconds is None: + return self + if ( + self.default_expires_in_seconds is not None + and self.default_expires_in_seconds > self.max_expires_in_seconds + ): + raise ValueError( + "auth.access_keys.default_expires_in_seconds must be less than or equal to " + "auth.access_keys.max_expires_in_seconds" + ) + return self + + class AuthConfig(create_service_config_class("auth")): # ty: ignore[unsupported-base] """ Shared authorization configuration read from the 'auth' key in config.yaml. @@ -259,6 +361,13 @@ class AuthConfig(create_service_config_class("auth")): # ty: ignore[unsupported The auth service extends this with additional fields (admin_email, etc.). """ + model_config = SettingsConfigDict( + env_prefix=get_service_config_prefix("auth"), + env_nested_delimiter="__", + extra="allow", + populate_by_name=True, + ) + enabled: bool = Field( default=False, description="Master switch for authorization. If False, all requests are allowed.", @@ -316,6 +425,29 @@ class AuthConfig(create_service_config_class("auth")): # ty: ignore[unsupported description="OIDC configuration for native token validation.", ) + token_signing: TokenSigningConfig = Field( + default_factory=TokenSigningConfig, + description="Shared token signing configuration for NeMo Platform-minted JWTs.", + ) + + access_keys: AccessKeyConfig = Field( + default_factory=AccessKeyConfig, + description="Scoped Access Key configuration.", + ) + + @model_validator(mode="after") + def validate_workload_token_signing_key_id(self) -> Self: + if not self.oidc.workload_token_exchange_enabled: + return self + + key_id = self.oidc.workload_token_key_id or self.token_signing.key_id + if not key_id or not key_id.strip(): + raise ValueError( + "auth.oidc.workload_token_key_id or auth.token_signing.key_id must be configured " + "when auth.oidc.workload_token_exchange_enabled is true" + ) + return self + def get_pdp_url(self, entrypoint: str) -> str: # Import lazily to avoid a module cycle: platform_endpoint imports # PlatformConfig from nmp.common.config, which is defined in this file. diff --git a/packages/nmp_common/tests/auth/test_access_keys.py b/packages/nmp_common/tests/auth/test_access_keys.py new file mode 100644 index 0000000000..8e27cfad0f --- /dev/null +++ b/packages/nmp_common/tests/auth/test_access_keys.py @@ -0,0 +1,619 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import httpx +import jwt +import nmp.common.auth.access_keys as access_keys_mod +import nmp.common.auth.signing_keys as signing_keys_mod +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyFeatureDisabledError +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest +from nmp.common import http_clients +from nmp.common.auth.access_keys import ( + ACCESS_KEY_TOKEN_TYPE, + AccessKeyIssuerService, + access_key_jwks_uri, + clear_access_key_signing_key_cache, + public_jwk_from_private_key_pem, + public_jwk_from_private_key_pem_async, + validate_access_key_token, +) +from nmp.common.auth.models import Principal +from nmp.common.config import AuthConfig +from nmp.common.config.base import AccessKeyConfig, TokenSigningConfig +from pydantic import ValidationError + + +def test_token_signing_defaults_are_shared_and_access_keys_are_disabled(): + config = AuthConfig() + + assert config.token_signing.issuer is None + assert config.token_signing.key_id == "nemo-platform-signing" + assert config.token_signing.private_key_file is None + assert config.access_keys.enabled is False + assert config.access_keys.issue_format == "jwt" + assert config.access_keys.accepted_formats == ["jwt"] + assert config.access_keys.audience == "nemo-platform-access-key" + assert config.access_keys.default_expires_in_seconds == 30 * 24 * 60 * 60 + assert config.access_keys.max_expires_in_seconds == 30 * 24 * 60 * 60 + + +def test_access_key_jwks_uri_uses_canonical_auth_jwks_path() -> None: + jwks_uri = access_key_jwks_uri(AuthConfig()) + + assert jwks_uri.endswith("/apis/auth/jwks") + assert "/access-keys/" not in jwks_uri + + +def test_token_signing_private_key_file_uses_auth_service_env_override(monkeypatch): + monkeypatch.setenv( + "NMP_AUTH_TOKEN_SIGNING__PRIVATE_KEY_FILE", + "/var/run/secrets/nemo-platform/token-signing/private.pem", + ) + + config = AuthConfig() + + assert config.token_signing.private_key_file == "/var/run/secrets/nemo-platform/token-signing/private.pem" + + +def test_workload_private_key_file_uses_auth_service_env_override(monkeypatch): + monkeypatch.setenv( + "NMP_AUTH_OIDC__WORKLOAD_TOKEN_PRIVATE_KEY_FILE", + "/var/run/secrets/nemo-platform/workload-token-signing/private-key.pem", + ) + + config = AuthConfig() + + assert ( + config.oidc.workload_token_private_key_file + == "/var/run/secrets/nemo-platform/workload-token-signing/private-key.pem" + ) + + +def test_nested_auth_env_vars_use_double_underscore_delimiter(monkeypatch): + monkeypatch.setenv("NMP_AUTH_TOKEN_SIGNING__KEY_ID", "custom-signing-key") + monkeypatch.setenv("NMP_AUTH_ACCESS_KEYS__ENABLED", "true") + + config = AuthConfig() + + assert config.token_signing.key_id == "custom-signing-key" + assert config.access_keys.enabled is True + + +@pytest.mark.parametrize( + ("env_value", "expected"), + [ + ("jwt", ["jwt"]), + ], +) +def test_access_key_accepted_formats_uses_auth_service_env_override(monkeypatch, env_value, expected): + monkeypatch.setenv("NMP_AUTH_ACCESS_KEYS__ACCEPTED_FORMATS", env_value) + + config = AuthConfig() + + assert config.access_keys.accepted_formats == expected + + +@pytest.mark.parametrize("env_value", ["jwt,opaque", "opaque", "jwt,unknown"]) +def test_access_key_accepted_formats_env_override_rejects_unsupported_format(monkeypatch, env_value): + monkeypatch.setenv("NMP_AUTH_ACCESS_KEYS__ACCEPTED_FORMATS", env_value) + + with pytest.raises(ValidationError): + AuthConfig() + + +@pytest.mark.parametrize( + ("env_value", "expected"), + [ + ("3600", 3600), + ("null", None), + ("none", None), + ("", None), + ], +) +def test_access_key_max_expires_in_seconds_uses_auth_service_env_override(monkeypatch, env_value, expected): + monkeypatch.setenv("NMP_AUTH_ACCESS_KEYS__MAX_EXPIRES_IN_SECONDS", env_value) + if expected is not None and expected < 30 * 24 * 60 * 60: + monkeypatch.setenv("NMP_AUTH_ACCESS_KEYS__DEFAULT_EXPIRES_IN_SECONDS", env_value) + + config = AuthConfig() + + assert config.access_keys.max_expires_in_seconds == expected + + +@pytest.mark.parametrize( + ("env_value", "expected"), + [ + ("3600", 3600), + ("null", None), + ("none", None), + ("", None), + ], +) +def test_access_key_default_expires_in_seconds_uses_auth_service_env_override(monkeypatch, env_value, expected): + monkeypatch.setenv("NMP_AUTH_ACCESS_KEYS__DEFAULT_EXPIRES_IN_SECONDS", env_value) + if expected is None: + monkeypatch.setenv("NMP_AUTH_ACCESS_KEYS__MAX_EXPIRES_IN_SECONDS", "none") + + config = AuthConfig() + + assert config.access_keys.default_expires_in_seconds == expected + + +def test_access_key_max_expires_in_seconds_env_override_rejects_zero(monkeypatch): + monkeypatch.setenv("NMP_AUTH_ACCESS_KEYS__MAX_EXPIRES_IN_SECONDS", "0") + + with pytest.raises(ValidationError): + AuthConfig() + + +def test_access_key_default_expiry_must_not_exceed_finite_max() -> None: + with pytest.raises(ValueError, match="default_expires_in_seconds"): + AccessKeyConfig(default_expires_in_seconds=3600, max_expires_in_seconds=60) + + +def test_access_key_default_expiry_can_be_none_when_max_is_finite() -> None: + config = AccessKeyConfig(default_expires_in_seconds=None, max_expires_in_seconds=60) + + assert config.default_expires_in_seconds is None + assert config.max_expires_in_seconds == 60 + + +def test_access_key_default_expiry_can_be_none_when_max_is_disabled() -> None: + config = AccessKeyConfig(default_expires_in_seconds=None, max_expires_in_seconds=None) + + assert config.default_expires_in_seconds is None + assert config.max_expires_in_seconds is None + + +def _private_key_pem() -> bytes: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +def _access_key_config( + tmp_path, + *, + default_expires_in_seconds: int | None = 30 * 24 * 60 * 60, + max_expires_in_seconds: int | None = 30 * 24 * 60 * 60, +): + key_path = tmp_path / "access-key-private.pem" + key_path.write_bytes(_private_key_pem()) + return AuthConfig( + enabled=True, + token_signing=TokenSigningConfig( + issuer="https://nmp.example.test/apis/auth", + key_id="test-access-key", + private_key_file=str(key_path), + ), + access_keys=AccessKeyConfig( + enabled=True, + audience="nemo-platform-access-key", + default_expires_in_seconds=default_expires_in_seconds, + max_expires_in_seconds=max_expires_in_seconds, + ), + ) + + +def test_access_key_public_jwk_uses_cached_private_key_file( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + private_key_file = tmp_path / "access-key-private.pem" + private_key_file.write_bytes(_private_key_pem()) + config = AuthConfig( + token_signing=TokenSigningConfig( + key_id="access-key", + private_key_file=str(private_key_file), + ), + access_keys=AccessKeyConfig(enabled=True), + ) + clear_access_key_signing_key_cache() + original_load = signing_keys_mod._load_rsa_signing_key_async + load_count = 0 + + async def counted_load(**kwargs: Any) -> signing_keys_mod.RSASigningKey: + nonlocal load_count + load_count += 1 + return await original_load(**kwargs) + + monkeypatch.setattr(signing_keys_mod, "_load_rsa_signing_key_async", counted_load) + + first = public_jwk_from_private_key_pem(config) + second = public_jwk_from_private_key_pem(config) + + assert first == second + assert first["kid"] == "access-key" + assert load_count == 1 + + +def test_access_key_private_key_uses_cached_private_key_file_for_token_creation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + private_key_file = tmp_path / "access-key-private.pem" + private_key_file.write_bytes(_private_key_pem()) + config = AuthConfig( + token_signing=TokenSigningConfig( + issuer="http://testserver/apis/auth", + key_id="access-key", + private_key_file=str(private_key_file), + ), + access_keys=AccessKeyConfig(enabled=True), + ) + principal = Principal(id="alice@example.com", email="alice@example.com", groups=[]) + clear_access_key_signing_key_cache() + original_load = signing_keys_mod._load_rsa_signing_key_async + load_count = 0 + + async def counted_load(**kwargs: Any) -> signing_keys_mod.RSASigningKey: + nonlocal load_count + load_count += 1 + return await original_load(**kwargs) + + monkeypatch.setattr(signing_keys_mod, "_load_rsa_signing_key_async", counted_load) + + issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1_785_280_000) + issuer.create(AccessKeyCreateRequest(name="first", expires_in_seconds=600)) + issuer.create(AccessKeyCreateRequest(name="second", expires_in_seconds=600)) + + assert load_count == 1 + + +def test_access_key_issuer_service_stamps_current_principal(tmp_path): + config = _access_key_config(tmp_path) + principal = Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]) + issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1785280000) + + created = issuer.create(AccessKeyCreateRequest(name="gtc-intake", expires_in_seconds=600)) + unverified = jwt.decode(created.token, options={"verify_signature": False}) + + assert created.jti.startswith("ak_") + assert created.name == "gtc-intake" + assert created.principal == "alice@example.com" + assert created.expires_at == datetime.fromtimestamp(1785280600, tz=UTC) + assert unverified["jti"] == created.jti + assert unverified["sub"] == "alice@example.com" + assert unverified["email"] == "alice@example.com" + assert unverified["groups"] == "team-ml" + assert unverified["nmp_token_type"] == ACCESS_KEY_TOKEN_TYPE + assert unverified["nmp_access_key"] == {"version": 1, "name": "gtc-intake"} + assert unverified["exp"] == 1785280600 + + +def test_access_key_issuer_service_serializes_groups_for_gateway_header(tmp_path): + config = _access_key_config(tmp_path) + principal = Principal( + id="system:serviceaccounts:nemo-authentik", + groups=["system:serviceaccounts", "system:serviceaccounts:nemo-authentik", "system:authenticated"], + ) + issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1785280000) + + created = issuer.create(AccessKeyCreateRequest(name="kubernetes-workload", expires_in_seconds=600)) + unverified = jwt.decode(created.token, options={"verify_signature": False}) + + assert unverified["groups"] == "system:serviceaccounts,system:serviceaccounts:nemo-authentik,system:authenticated" + + +def test_access_key_issuer_service_allows_unnamed_tokens(tmp_path): + config = _access_key_config(tmp_path) + principal = Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]) + issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1785280000) + + created = issuer.create(AccessKeyCreateRequest(expires_in_seconds=600)) + unverified = jwt.decode(created.token, options={"verify_signature": False}) + + assert created.jti.startswith("ak_") + assert created.name is None + assert unverified["jti"] == created.jti + assert unverified["nmp_access_key"] == {"version": 1} + assert unverified["exp"] == 1785280600 + + +def test_access_key_issuer_service_respects_disabled_config(tmp_path): + base_config = _access_key_config(tmp_path) + config = base_config.model_copy( + update={"access_keys": base_config.access_keys.model_copy(update={"enabled": False})} + ) + principal = Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]) + issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1785280000) + + with pytest.raises(AccessKeyFeatureDisabledError, match="not enabled"): + issuer.create(AccessKeyCreateRequest()) + + +def test_access_key_issuer_service_rejects_expiration_above_configured_max(tmp_path): + config = _access_key_config(tmp_path) + config = config.model_copy( + update={"access_keys": config.access_keys.model_copy(update={"max_expires_in_seconds": 60})} + ) + issuer = AccessKeyIssuerService( + config=config, + principal=Principal(id="alice@example.com", email="alice@example.com"), + now=lambda: 1785280000, + ) + + with pytest.raises(RuntimeError, match="max_expires_in_seconds"): + issuer.create(AccessKeyCreateRequest(name="too-long", expires_in_seconds=61)) + + +def test_access_key_issuer_service_defaults_omitted_expiration_to_config_default(tmp_path): + config = _access_key_config(tmp_path) + issuer = AccessKeyIssuerService( + config=config, + principal=Principal(id="alice@example.com", email="alice@example.com"), + now=lambda: 1785280000, + ) + + created = issuer.create(AccessKeyCreateRequest(name="default-expiry")) + unverified = jwt.decode(created.token, options={"verify_signature": False}) + + expected_exp = 1785280000 + 30 * 24 * 60 * 60 + assert unverified["exp"] == expected_exp + assert created.expires_at == datetime.fromtimestamp(expected_exp, tz=UTC) + + +def test_access_key_issuer_service_rejects_explicit_null_expiration_when_max_configured(tmp_path): + config = _access_key_config(tmp_path) + issuer = AccessKeyIssuerService( + config=config, + principal=Principal(id="alice@example.com", email="alice@example.com"), + now=lambda: 1785280000, + ) + + with pytest.raises(RuntimeError, match="expires_in_seconds=null requires"): + issuer.create(AccessKeyCreateRequest(name="unlimited", expires_in_seconds=None)) + + +def test_access_key_issuer_service_defaults_expiration_when_max_disabled(tmp_path): + config = _access_key_config(tmp_path, max_expires_in_seconds=None) + issuer = AccessKeyIssuerService( + config=config, + principal=Principal(id="alice@example.com", email="alice@example.com"), + now=lambda: 1785280000, + ) + + created = issuer.create(AccessKeyCreateRequest(name="default-even-without-max")) + unverified = jwt.decode(created.token, options={"verify_signature": False}) + + expected_exp = 1785280000 + 30 * 24 * 60 * 60 + assert unverified["exp"] == expected_exp + assert created.expires_at == datetime.fromtimestamp(expected_exp, tz=UTC) + + +def test_access_key_issuer_service_allows_explicit_null_expiration_when_max_disabled(tmp_path): + config = _access_key_config(tmp_path, max_expires_in_seconds=None) + issuer = AccessKeyIssuerService( + config=config, + principal=Principal(id="alice@example.com", email="alice@example.com"), + now=lambda: 1785280000, + ) + + created = issuer.create(AccessKeyCreateRequest(name="long-lived", expires_in_seconds=None)) + unverified = jwt.decode(created.token, options={"verify_signature": False}) + + assert created.expires_at is None + assert "exp" not in unverified + + +def test_access_key_issuer_service_allows_expiration_above_default_when_max_disabled(tmp_path): + config = _access_key_config(tmp_path, max_expires_in_seconds=None) + issuer = AccessKeyIssuerService( + config=config, + principal=Principal(id="alice@example.com", email="alice@example.com"), + now=lambda: 1785280000, + ) + + created = issuer.create(AccessKeyCreateRequest(name="long-lived", expires_in_seconds=31 * 24 * 60 * 60)) + + assert created.expires_at == datetime.fromtimestamp(1787958400, tz=UTC) + + +def test_access_key_issuer_service_requires_expiration_when_default_disabled_and_max_configured(tmp_path): + config = _access_key_config(tmp_path, default_expires_in_seconds=None, max_expires_in_seconds=60) + issuer = AccessKeyIssuerService( + config=config, + principal=Principal(id="alice@example.com", email="alice@example.com"), + now=lambda: 1785280000, + ) + + with pytest.raises(RuntimeError, match="expires_in_seconds is required"): + issuer.create(AccessKeyCreateRequest(name="must-set-expiry")) + + created = issuer.create(AccessKeyCreateRequest(name="finite-expiry", expires_in_seconds=60)) + unverified = jwt.decode(created.token, options={"verify_signature": False}) + + assert unverified["exp"] == 1785280060 + assert created.expires_at == datetime.fromtimestamp(1785280060, tz=UTC) + + +def test_access_key_issuer_service_allows_omitted_expiration_when_default_and_max_disabled(tmp_path): + config = _access_key_config(tmp_path, default_expires_in_seconds=None, max_expires_in_seconds=None) + issuer = AccessKeyIssuerService( + config=config, + principal=Principal(id="alice@example.com", email="alice@example.com"), + now=lambda: 1785280000, + ) + + created = issuer.create(AccessKeyCreateRequest(name="deployment-default-unlimited")) + unverified = jwt.decode(created.token, options={"verify_signature": False}) + + assert created.expires_at is None + assert "exp" not in unverified + + +def test_access_key_issuer_service_honors_finite_expiration_when_default_and_max_disabled(tmp_path): + config = _access_key_config(tmp_path, default_expires_in_seconds=None, max_expires_in_seconds=None) + issuer = AccessKeyIssuerService( + config=config, + principal=Principal(id="alice@example.com", email="alice@example.com"), + now=lambda: 1785280000, + ) + + created = issuer.create(AccessKeyCreateRequest(name="finite-in-unlimited-policy", expires_in_seconds=600)) + unverified = jwt.decode(created.token, options={"verify_signature": False}) + + assert unverified["exp"] == 1785280600 + assert created.expires_at == datetime.fromtimestamp(1785280600, tz=UTC) + + +async def test_validate_access_key_token_returns_token_claims(tmp_path): + config = _access_key_config(tmp_path, max_expires_in_seconds=None) + principal = Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]) + issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1785280000) + created = await issuer.create_async(AccessKeyCreateRequest(name="gtc-intake", expires_in_seconds=None)) + jwks = {"keys": [await public_jwk_from_private_key_pem_async(config)]} + + claims = await validate_access_key_token(config, created.token, jwks_override=jwks) + + assert claims is not None + assert claims.subject == "alice@example.com" + assert claims.email == "alice@example.com" + assert claims.groups == ["team-ml"] + assert claims.scopes == [] + + +async def test_validate_access_key_token_accepts_legacy_list_groups_claim(tmp_path): + config = _access_key_config(tmp_path, max_expires_in_seconds=None) + now = 1785280000 + signing_key = await access_keys_mod._access_key_signing_key_async(config) + token = jwt.encode( + { + "iss": access_keys_mod.access_key_issuer(config), + "aud": config.access_keys.audience, + "sub": "alice@example.com", + "iat": now, + "nbf": now, + "jti": "ak_legacy", + "nmp_token_type": ACCESS_KEY_TOKEN_TYPE, + "nmp_access_key": {"version": 1}, + "groups": ["team-ml", "team-ai"], + }, + signing_key.private_key, + algorithm="RS256", + headers={"kid": config.token_signing.key_id}, + ) + jwks = {"keys": [await public_jwk_from_private_key_pem_async(config)]} + + claims = await validate_access_key_token(config, token, jwks_override=jwks) + + assert claims is not None + assert claims.groups == ["team-ml", "team-ai"] + + +async def test_validate_access_key_token_fetches_remote_jwks_once_with_async_client(tmp_path, monkeypatch): + config = _access_key_config(tmp_path, max_expires_in_seconds=None) + principal = Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]) + issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1785280000) + created = await issuer.create_async(AccessKeyCreateRequest(name="gtc-intake", expires_in_seconds=None)) + jwks = {"keys": [await public_jwk_from_private_key_pem_async(config)]} + jwks_uri = f"https://auth.example.test/{id(jwks)}/jwks" + + class ForbiddenPyJWKClient: + def __init__(self, *args, **kwargs): + raise AssertionError("Access-key validation should use the shared async HTTP client") + + class FakeResponse: + def raise_for_status(self) -> None: + pass + + def json(self) -> dict: + return jwks + + class FakeAsyncClient: + def __init__(self) -> None: + self.calls = 0 + + async def get(self, url: str, *, timeout: float) -> FakeResponse: + assert url == jwks_uri + assert timeout == 10.0 + self.calls += 1 + return FakeResponse() + + fake_client = FakeAsyncClient() + monkeypatch.setattr(access_keys_mod, "access_key_jwks_uri", lambda config: jwks_uri) + monkeypatch.setattr(http_clients, "shared_async_http_client", lambda: fake_client) + monkeypatch.setattr(access_keys_mod.jwt, "PyJWKClient", ForbiddenPyJWKClient) + + first_claims = await validate_access_key_token(config, created.token) + second_claims = await validate_access_key_token(config, created.token) + + assert first_claims is not None + assert second_claims is not None + assert first_claims.subject == "alice@example.com" + assert second_claims.subject == "alice@example.com" + assert fake_client.calls == 1 + + +async def test_validate_access_key_token_propagates_remote_jwks_fetch_failure(tmp_path, monkeypatch): + config = _access_key_config(tmp_path, max_expires_in_seconds=None) + principal = Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]) + issuer = AccessKeyIssuerService(config=config, principal=principal, now=lambda: 1785280000) + created = await issuer.create_async(AccessKeyCreateRequest(name="gtc-intake", expires_in_seconds=None)) + jwks_uri = "https://auth.example.test/jwks" + + class FakeResponse: + def raise_for_status(self) -> None: + request = httpx.Request("GET", jwks_uri) + response = httpx.Response(503, request=request) + raise httpx.HTTPStatusError("JWKS unavailable", request=request, response=response) + + class FakeAsyncClient: + async def get(self, url: str, *, timeout: float) -> FakeResponse: + assert url == jwks_uri + assert timeout == 10.0 + return FakeResponse() + + monkeypatch.setattr(access_keys_mod, "access_key_jwks_uri", lambda config: jwks_uri) + monkeypatch.setattr(http_clients, "shared_async_http_client", lambda: FakeAsyncClient()) + + with pytest.raises(httpx.HTTPStatusError): + await validate_access_key_token(config, created.token) + + +async def test_validate_access_key_token_rejects_wrong_audience(tmp_path): + config = _access_key_config(tmp_path, max_expires_in_seconds=None) + wrong_config = config.model_copy( + update={"access_keys": config.access_keys.model_copy(update={"audience": "different-audience"})} + ) + issuer = AccessKeyIssuerService( + config=config, + principal=Principal(id="alice@example.com", email="alice@example.com"), + now=lambda: 1785280000, + ) + created = await issuer.create_async(AccessKeyCreateRequest(name="gtc-intake", expires_in_seconds=None)) + jwks = {"keys": [await public_jwk_from_private_key_pem_async(config)]} + + assert await validate_access_key_token(wrong_config, created.token, jwks_override=jwks) is None + + +async def test_validate_access_key_token_rejects_service_principal_subject(tmp_path): + config = _access_key_config(tmp_path) + issuer = AccessKeyIssuerService(config=config, principal=Principal(id="service:jobs"), now=lambda: 1785280000) + + with pytest.raises(RuntimeError, match="service principals"): + await issuer.create_async(AccessKeyCreateRequest(name="bad-service-key", expires_in_seconds=600)) + + +async def test_validate_access_key_token_rejects_expired_key(tmp_path): + config = _access_key_config(tmp_path) + issuer = AccessKeyIssuerService( + config=config, + principal=Principal(id="alice@example.com", email="alice@example.com"), + now=lambda: 1785280000, + ) + created = await issuer.create_async(AccessKeyCreateRequest(name="short-lived", expires_in_seconds=60)) + jwks = {"keys": [await public_jwk_from_private_key_pem_async(config)]} + + assert created.expires_at == datetime.fromtimestamp(1785280060, tz=UTC) + assert await validate_access_key_token(config, created.token, jwks_override=jwks, now=1785280061) is None diff --git a/packages/nmp_common/tests/auth/test_bearer.py b/packages/nmp_common/tests/auth/test_bearer.py new file mode 100644 index 0000000000..cfa95450e4 --- /dev/null +++ b/packages/nmp_common/tests/auth/test_bearer.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from nmp.common.auth.bearer import MalformedBearerTokenError, parse_bearer_authorization_header + + +@pytest.mark.parametrize( + ("auth_header", "expected"), + [ + (None, None), + ("", None), + ("Basic token", None), + ("Bearer token", "token"), + ("bearer token", "token"), + ("Bearer token", "token"), + (" Bearer token ", "token"), + ], +) +def test_parse_bearer_authorization_header(auth_header, expected): + assert parse_bearer_authorization_header(auth_header) == expected + + +@pytest.mark.parametrize( + "auth_header", + [ + "Bearer", + "Bearer ", + "Bearer token extra", + ], +) +def test_parse_bearer_authorization_header_rejects_malformed_bearer(auth_header): + with pytest.raises(MalformedBearerTokenError): + parse_bearer_authorization_header(auth_header) diff --git a/packages/nmp_common/tests/auth/test_jwks.py b/packages/nmp_common/tests/auth/test_jwks.py new file mode 100644 index 0000000000..70ea2be96d --- /dev/null +++ b/packages/nmp_common/tests/auth/test_jwks.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import asyncio +from typing import Any + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.algorithms import RSAAlgorithm +from nmp.common import http_clients +from nmp.common.auth.jwks import AsyncJWKSClient + +JWKS_URI = "https://auth.example.test/jwks" + + +def _rsa_key_and_jwk(kid: str) -> tuple[Any, dict[str, Any]]: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + jwk = RSAAlgorithm.to_jwk(private_key.public_key(), as_dict=True) + jwk.update({"kid": kid, "use": "sig", "alg": "RS256"}) + return private_key, jwk + + +def _token(private_key: Any, *, kid: str | None) -> str: + headers = {"kid": kid} if kid is not None else None + return jwt.encode({"sub": "user"}, private_key, algorithm="RS256", headers=headers) + + +class FakeResponse: + def __init__(self, jwks: dict[str, Any]) -> None: + self._jwks = jwks + + def raise_for_status(self) -> None: + pass + + def json(self) -> dict[str, Any]: + return self._jwks + + +class FakeAsyncClient: + def __init__(self, jwks: dict[str, Any]) -> None: + self._jwks = jwks + self.calls = 0 + + async def get(self, url: str, *, timeout: float) -> FakeResponse: + assert url == JWKS_URI + assert timeout == 10.0 + self.calls += 1 + await asyncio.sleep(0.01) + return FakeResponse(self._jwks) + + +class SequenceFakeAsyncClient: + def __init__(self, jwks_responses: list[dict[str, Any]]) -> None: + self._jwks_responses = jwks_responses + self.calls = 0 + + async def get(self, url: str, *, timeout: float) -> FakeResponse: + assert url == JWKS_URI + assert timeout == 10.0 + self.calls += 1 + await asyncio.sleep(0.01) + if len(self._jwks_responses) > 1: + return FakeResponse(self._jwks_responses.pop(0)) + return FakeResponse(self._jwks_responses[0]) + + +@pytest.mark.parametrize("bad_token", ["malformed-token", None]) +async def test_cached_jwks_lookup_does_not_refresh_malformed_or_missing_kid_tokens(monkeypatch, bad_token): + private_key, jwk = _rsa_key_and_jwk("known-key") + fake_client = FakeAsyncClient({"keys": [jwk]}) + monkeypatch.setattr(http_clients, "shared_async_http_client", lambda: fake_client) + client = AsyncJWKSClient(JWKS_URI) + await client.get_signing_key_from_jwt(_token(private_key, kid="known-key")) + + token = bad_token if bad_token is not None else _token(private_key, kid=None) + with pytest.raises(jwt.InvalidTokenError): + await client.get_signing_key_from_jwt(token) + + assert fake_client.calls == 1 + + +async def test_cached_unknown_kid_concurrent_refresh_uses_single_refreshed_jwks(monkeypatch): + known_private_key, known_jwk = _rsa_key_and_jwk("known-key") + rotated_private_key, rotated_jwk = _rsa_key_and_jwk("rotated-key") + fake_client = SequenceFakeAsyncClient([{"keys": [known_jwk]}, {"keys": [rotated_jwk]}]) + monkeypatch.setattr(http_clients, "shared_async_http_client", lambda: fake_client) + client = AsyncJWKSClient(JWKS_URI) + await client.get_signing_key_from_jwt(_token(known_private_key, kid="known-key")) + rotated_token = _token(rotated_private_key, kid="rotated-key") + + results = await asyncio.gather(*(client.get_signing_key_from_jwt(rotated_token) for _ in range(5))) + + assert [result.key_id for result in results] == ["rotated-key"] * 5 + assert fake_client.calls == 2 + + +async def test_cached_unknown_kid_refresh_is_rate_limited(monkeypatch): + private_key, jwk = _rsa_key_and_jwk("known-key") + fake_client = FakeAsyncClient({"keys": [jwk]}) + monkeypatch.setattr(http_clients, "shared_async_http_client", lambda: fake_client) + client = AsyncJWKSClient(JWKS_URI) + await client.get_signing_key_from_jwt(_token(private_key, kid="known-key")) + unknown_token = _token(private_key, kid="unknown-key") + + results = await asyncio.gather( + *(client.get_signing_key_from_jwt(unknown_token) for _ in range(5)), + return_exceptions=True, + ) + + assert all(isinstance(result, jwt.InvalidTokenError) for result in results) + assert fake_client.calls == 2 + + with pytest.raises(jwt.InvalidTokenError): + await client.get_signing_key_from_jwt(unknown_token) + + assert fake_client.calls == 2 diff --git a/packages/nmp_common/tests/auth/test_jwt.py b/packages/nmp_common/tests/auth/test_jwt.py index da7ae7060e..e31f91f014 100644 --- a/packages/nmp_common/tests/auth/test_jwt.py +++ b/packages/nmp_common/tests/auth/test_jwt.py @@ -9,6 +9,9 @@ import httpx import jwt import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.algorithms import RSAAlgorithm +from nmp.common import http_clients from nmp.common.auth.jwt import JWTValidator, TokenClaims, UnsignedJWTRejectedError from nmp.common.config import AuthConfig from nmp.common.config.base import OIDCConfig @@ -196,7 +199,7 @@ async def test_validate_token_expired(self, jwt_validator): mock_jwks = MagicMock() mock_signing_key = MagicMock() mock_signing_key.key = "test-key" - mock_jwks.get_signing_key_from_jwt.return_value = mock_signing_key + mock_jwks.get_signing_key_from_jwt = AsyncMock(return_value=mock_signing_key) mock_get_jwks.return_value = mock_jwks # Make jwt.decode raise ExpiredSignatureError @@ -212,7 +215,7 @@ async def test_validate_token_invalid_audience(self, jwt_validator): mock_jwks = MagicMock() mock_signing_key = MagicMock() mock_signing_key.key = "test-key" - mock_jwks.get_signing_key_from_jwt.return_value = mock_signing_key + mock_jwks.get_signing_key_from_jwt = AsyncMock(return_value=mock_signing_key) mock_get_jwks.return_value = mock_jwks with patch("jwt.decode", side_effect=jwt.InvalidAudienceError("Invalid audience")): @@ -227,7 +230,7 @@ async def test_validate_token_invalid_issuer(self, jwt_validator): mock_jwks = MagicMock() mock_signing_key = MagicMock() mock_signing_key.key = "test-key" - mock_jwks.get_signing_key_from_jwt.return_value = mock_signing_key + mock_jwks.get_signing_key_from_jwt = AsyncMock(return_value=mock_signing_key) mock_get_jwks.return_value = mock_jwks with patch("jwt.decode", side_effect=jwt.InvalidIssuerError("Invalid issuer")): @@ -371,7 +374,7 @@ async def test_validate_token_success(self, jwt_validator): mock_jwks = MagicMock() mock_signing_key = MagicMock() mock_signing_key.key = "test-key" - mock_jwks.get_signing_key_from_jwt.return_value = mock_signing_key + mock_jwks.get_signing_key_from_jwt = AsyncMock(return_value=mock_signing_key) mock_get_jwks.return_value = mock_jwks with patch("jwt.decode", return_value=valid_claims): @@ -399,7 +402,7 @@ async def test_validate_token_with_string_groups(self, jwt_validator): mock_jwks = MagicMock() mock_signing_key = MagicMock() mock_signing_key.key = "test-key" - mock_jwks.get_signing_key_from_jwt.return_value = mock_signing_key + mock_jwks.get_signing_key_from_jwt = AsyncMock(return_value=mock_signing_key) mock_get_jwks.return_value = mock_jwks with patch("jwt.decode", return_value=valid_claims): @@ -424,7 +427,7 @@ async def test_validate_token_with_cognito_groups(self, jwt_validator): mock_jwks = MagicMock() mock_signing_key = MagicMock() mock_signing_key.key = "test-key" - mock_jwks.get_signing_key_from_jwt.return_value = mock_signing_key + mock_jwks.get_signing_key_from_jwt = AsyncMock(return_value=mock_signing_key) mock_get_jwks.return_value = mock_jwks with patch("jwt.decode", return_value=valid_claims): @@ -463,7 +466,7 @@ async def test_validate_token_skips_audience_when_not_configured(self): mock_jwks = MagicMock() mock_signing_key = MagicMock() mock_signing_key.key = "test-key" - mock_jwks.get_signing_key_from_jwt.return_value = mock_signing_key + mock_jwks.get_signing_key_from_jwt = AsyncMock(return_value=mock_signing_key) mock_get_jwks.return_value = mock_jwks with patch("jwt.decode", return_value=valid_claims) as mock_decode: @@ -505,7 +508,7 @@ async def test_validate_token_validates_audience_when_configured(self): mock_jwks = MagicMock() mock_signing_key = MagicMock() mock_signing_key.key = "test-key" - mock_jwks.get_signing_key_from_jwt.return_value = mock_signing_key + mock_jwks.get_signing_key_from_jwt = AsyncMock(return_value=mock_signing_key) mock_get_jwks.return_value = mock_jwks with patch("jwt.decode", return_value=valid_claims) as mock_decode: @@ -537,7 +540,7 @@ async def test_validate_token_uses_configured_jwks_uri(self, auth_config): auth_config.oidc.jwks_uri = "https://custom.example.com/jwks" validator = JWTValidator(auth_config) - with patch("nmp.common.auth.jwt.PyJWKClient") as mock_jwk_client_class: + with patch("nmp.common.auth.jwt.AsyncJWKSClient") as mock_jwk_client_class: mock_jwks = MagicMock() mock_jwk_client_class.return_value = mock_jwks @@ -545,6 +548,63 @@ async def test_validate_token_uses_configured_jwks_uri(self, auth_config): mock_jwk_client_class.assert_called_once_with( "https://custom.example.com/jwks", - cache_keys=True, lifespan=_JWKS_CACHE_LIFESPAN, ) + + @pytest.mark.asyncio + async def test_validate_token_fetches_jwks_with_async_client(self, auth_config, monkeypatch): + """OIDC JWKS lookup must not use sync PyJWKClient in async validation.""" + auth_config.oidc.jwks_uri = "https://custom.example.com/jwks" + validator = JWTValidator(auth_config) + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + jwk = RSAAlgorithm.to_jwk(private_key.public_key(), as_dict=True) + jwk.update({"kid": "oidc-key", "use": "sig", "alg": "RS256"}) + token = jwt.encode( + { + "sub": "user123", + "email": "user@example.com", + "groups": ["admin"], + "scope": "openid profile", + "exp": int(time.time()) + 3600, + "iat": int(time.time()), + "aud": "test-audience", + "iss": "https://sso.example.com", + }, + private_key, + algorithm="RS256", + headers={"kid": "oidc-key"}, + ) + + class ForbiddenPyJWKClient: + def __init__(self, *args, **kwargs): + raise AssertionError("OIDC validation should use async JWKS fetching") + + class FakeResponse: + def raise_for_status(self) -> None: + pass + + def json(self) -> dict: + return {"keys": [jwk]} + + class FakeAsyncClient: + def __init__(self) -> None: + self.calls = 0 + + async def get(self, url: str, *, timeout: float) -> FakeResponse: + assert url == "https://custom.example.com/jwks" + assert timeout == 10.0 + self.calls += 1 + return FakeResponse() + + fake_client = FakeAsyncClient() + monkeypatch.setattr(http_clients, "shared_async_http_client", lambda: fake_client) + monkeypatch.setattr("nmp.common.auth.jwt.PyJWKClient", ForbiddenPyJWKClient, raising=False) + + first_claims = await validator.validate_token(token) + second_claims = await validator.validate_token(token) + + assert first_claims is not None + assert second_claims is not None + assert first_claims.subject == "user123" + assert second_claims.subject == "user123" + assert fake_client.calls == 1 diff --git a/packages/nmp_common/tests/auth/test_loading_cache.py b/packages/nmp_common/tests/auth/test_loading_cache.py new file mode 100644 index 0000000000..4290fb6642 --- /dev/null +++ b/packages/nmp_common/tests/auth/test_loading_cache.py @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio + +import pytest +from nmp.common.auth.loading_cache import AsyncCoalescingLoader, AsyncLoadingCache + + +async def test_async_coalescing_loader_shares_in_flight_load() -> None: + loader: AsyncCoalescingLoader[object] = AsyncCoalescingLoader() + load_count = 0 + + async def load() -> object: + nonlocal load_count + load_count += 1 + await asyncio.sleep(0.01) + return object() + + values = await asyncio.gather(*(loader.load(load) for _ in range(20))) + + assert all(value is values[0] for value in values) + assert load_count == 1 + + +async def test_async_loading_cache_loads_value_once_for_same_key() -> None: + cache: AsyncLoadingCache[str, object] = AsyncLoadingCache() + load_count = 0 + + async def load() -> object: + nonlocal load_count + load_count += 1 + return object() + + first = await cache.get_or_load("key", load) + second = await cache.get_or_load("key", load) + + assert first is second + assert load_count == 1 + + +async def test_async_loading_cache_serializes_concurrent_misses() -> None: + cache: AsyncLoadingCache[str, object] = AsyncLoadingCache() + load_count = 0 + + async def load() -> object: + nonlocal load_count + load_count += 1 + await asyncio.sleep(0.01) + return object() + + values = await asyncio.gather(*(cache.get_or_load("key", load) for _ in range(20))) + + assert all(value is values[0] for value in values) + assert load_count == 1 + + +async def test_async_loading_cache_does_not_cache_loader_failure() -> None: + cache: AsyncLoadingCache[str, str] = AsyncLoadingCache() + load_count = 0 + + async def load() -> str: + nonlocal load_count + load_count += 1 + if load_count == 1: + raise ValueError("failed") + return "loaded" + + with pytest.raises(ValueError, match="failed"): + await cache.get_or_load("key", load) + + assert await cache.get_or_load("key", load) == "loaded" + assert load_count == 2 + + +async def test_async_coalescing_loader_does_not_cache_completed_loads() -> None: + loader: AsyncCoalescingLoader[object] = AsyncCoalescingLoader() + load_count = 0 + + async def load() -> object: + nonlocal load_count + load_count += 1 + return object() + + first = await loader.load(load) + second = await loader.load(load) + + assert second is not first + assert load_count == 2 + + +async def test_async_coalescing_loader_returns_rate_limited_value_after_recent_load() -> None: + loader: AsyncCoalescingLoader[str] = AsyncCoalescingLoader(min_interval_seconds=60.0) + load_count = 0 + + async def load() -> str: + nonlocal load_count + load_count += 1 + return "fresh" + + assert await loader.load(load, rate_limited_value=lambda: "cached") == "fresh" + assert await loader.load(load, rate_limited_value=lambda: "cached") == "cached" + assert load_count == 1 + + +async def test_async_coalescing_loader_zero_interval_disables_rate_limit_window() -> None: + loader: AsyncCoalescingLoader[str] = AsyncCoalescingLoader(min_interval_seconds=0) + load_count = 0 + + async def load() -> str: + nonlocal load_count + load_count += 1 + return f"fresh-{load_count}" + + assert await loader.load(load, rate_limited_value=lambda: "cached") == "fresh-1" + assert await loader.load(load, rate_limited_value=lambda: "cached") == "fresh-2" + assert load_count == 2 + + +async def test_async_coalescing_loader_clear_resets_rate_limit_window() -> None: + loader: AsyncCoalescingLoader[str] = AsyncCoalescingLoader(min_interval_seconds=60.0) + load_count = 0 + + async def load() -> str: + nonlocal load_count + load_count += 1 + return f"fresh-{load_count}" + + assert await loader.load(load, rate_limited_value=lambda: "cached") == "fresh-1" + assert await loader.load(load, rate_limited_value=lambda: "cached") == "cached" + + await loader.clear() + + assert await loader.load(load, rate_limited_value=lambda: "cached") == "fresh-2" + assert load_count == 2 + + +async def test_async_coalescing_loader_does_not_cache_loader_failure() -> None: + loader: AsyncCoalescingLoader[str] = AsyncCoalescingLoader() + load_count = 0 + + async def load() -> str: + nonlocal load_count + load_count += 1 + if load_count == 1: + raise ValueError("failed") + return "loaded" + + with pytest.raises(ValueError, match="failed"): + await loader.load(load) + + assert await loader.load(load) == "loaded" + assert load_count == 2 diff --git a/packages/nmp_common/tests/auth/test_middleware.py b/packages/nmp_common/tests/auth/test_middleware.py index 23f211c79a..bd62f85146 100644 --- a/packages/nmp_common/tests/auth/test_middleware.py +++ b/packages/nmp_common/tests/auth/test_middleware.py @@ -8,12 +8,14 @@ import jwt import pytest -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.testclient import TestClient from nmp.common.auth.client import AuthClient +from nmp.common.auth.dependencies import get_auth_client from nmp.common.auth.jwt import TokenClaims, UnsignedJWTRejectedError from nmp.common.auth.middleware import HEALTH_ENDPOINTS, PUBLIC_GET_PATHS, AuthorizationMiddleware from nmp.common.auth.models import Principal +from nmp.common.auth.token_resolver import ResolvedBearerToken from nmp.common.config import AuthConfig, Configuration from nmp.common.config.base import OIDCConfig @@ -229,6 +231,26 @@ def test_bearer_token_invalid_token(self, auth_config_enabled): assert response.status_code == 401 assert "Invalid or expired token" in response.json()["detail"] + @pytest.mark.parametrize( + "auth_header", + [ + "Bearer", + "Bearer ", + "Bearer token extra", + ], + ) + def test_malformed_bearer_token_returns_401(self, auth_config_enabled, auth_header): + """Malformed Bearer headers fail auth instead of falling through as anonymous requests.""" + app = create_test_app(auth_config_enabled) + client = TestClient(app, raise_server_exceptions=False) + + with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize: + response = client.get("/test", headers={"Authorization": auth_header}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid bearer token" + mock_authorize.assert_not_called() + def test_bearer_token_expired_unsigned_jwt_returns_401(self): """Expired unsigned JWTs are rejected when allow_unsigned_jwt is true.""" config = AuthConfig( @@ -381,6 +403,193 @@ def test_bearer_token_valid_token_pdp_denies(self, auth_config_enabled): assert response.status_code == 403 + def test_bearer_token_scoped_access_key_accepted_without_oidc(self, auth_config_oidc_disabled): + config = auth_config_oidc_disabled.model_copy( + update={"access_keys": auth_config_oidc_disabled.access_keys.model_copy(update={"enabled": True})} + ) + app = create_test_app(config) + client = TestClient(app, raise_server_exceptions=False) + + valid_claims = TokenClaims( + subject="alice@example.com", + email="alice@example.com", + groups=["team-ml"], + scopes=[], + raw_claims={"nmp_token_type": "access_key"}, + ) + + with patch("nmp.common.auth.access_keys.validate_access_key_token") as mock_validate: + mock_validate.return_value = valid_claims + with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize: + mock_authorize.return_value = MagicMock(allowed=True) + + response = client.get("/test", headers={"Authorization": "Bearer scoped-access-key"}) + + assert response.status_code == 200 + mock_authorize.assert_called_once() + + def test_bearer_token_scoped_access_key_invalid_falls_back_to_oidc(self, auth_config_enabled): + config = auth_config_enabled.model_copy( + update={"access_keys": auth_config_enabled.access_keys.model_copy(update={"enabled": True})} + ) + app = create_test_app(config) + client = TestClient(app, raise_server_exceptions=False) + + oidc_claims = TokenClaims( + subject="bob@example.com", + email="bob@example.com", + groups=[], + scopes=[], + raw_claims={}, + ) + + with patch("nmp.common.auth.access_keys.validate_access_key_token") as mock_access_key_validate: + mock_access_key_validate.return_value = None + with patch("nmp.common.auth.jwt.JWTValidator.validate_token") as mock_oidc_validate: + mock_oidc_validate.return_value = oidc_claims + with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize: + mock_authorize.return_value = MagicMock(allowed=True) + + response = client.get("/test", headers={"Authorization": "Bearer oidc-token"}) + + assert response.status_code == 200 + mock_access_key_validate.assert_called_once() + mock_oidc_validate.assert_called_once() + + def test_scoped_access_key_middleware_mapping_is_skipped_when_access_keys_are_disabled( + self, + auth_config_oidc_disabled, + ): + app = create_test_app(auth_config_oidc_disabled) + client = TestClient(app, raise_server_exceptions=False) + + with patch("nmp.common.auth.access_keys.validate_access_key_token") as mock_validate: + response = client.get("/test", headers={"Authorization": "Bearer scoped-access-key"}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Bearer token authentication not configured" + mock_validate.assert_not_called() + + def test_bearer_token_request_uses_shared_resolver(self, auth_config_enabled): + app = create_test_app(auth_config_enabled) + client = TestClient(app, raise_server_exceptions=False) + claims = TokenClaims( + subject="alice@example.com", + email="alice@example.com", + groups=["team-ml"], + scopes=["models:read"], + raw_claims={}, + ) + resolved = ResolvedBearerToken(claims=claims, token_kind="oidc_access_token") + + with patch( + "nmp.common.auth.middleware.resolve_bearer_token", + new=AsyncMock(return_value=resolved), + ) as resolver: + with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize: + mock_authorize.return_value = MagicMock(allowed=True) + response = client.get("/test", headers={"Authorization": "Bearer oidc-token"}) + + assert response.status_code == 200 + resolver.assert_awaited_once() + mock_authorize.assert_called_once() + + def test_bearer_token_sets_auth_client_context_for_service_handler(self, auth_config_enabled): + app = FastAPI() + + @app.get("/whoami") + async def whoami(auth_client: AuthClient = Depends(get_auth_client)): + principal = auth_client.principal + return { + "principal": principal.id, + "email": principal.email, + "groups": principal.groups, + } + + Configuration.set_override(auth_config_enabled) + app.add_middleware(AuthorizationMiddleware, service_name="test-service") + client = TestClient(app, raise_server_exceptions=False) + claims = TokenClaims( + subject="alice@example.com", + email="alice@example.com", + groups=["team-ml", "team-ai"], + scopes=["models:read"], + raw_claims={}, + ) + resolved = ResolvedBearerToken(claims=claims, token_kind="access_key") + + with patch( + "nmp.common.auth.middleware.resolve_bearer_token", + new=AsyncMock(return_value=resolved), + ) as resolver: + with patch.object(AuthClient, "authorize_request", autospec=True) as mock_authorize: + mock_authorize.return_value = MagicMock(allowed=True) + response = client.get("/whoami", headers={"Authorization": "Bearer scoped-access-key"}) + + assert response.status_code == 200 + assert response.json() == { + "principal": "alice@example.com", + "email": "alice@example.com", + "groups": ["team-ml", "team-ai"], + } + resolver.assert_awaited_once() + mock_authorize.assert_called_once() + assert mock_authorize.call_args.kwargs["scopes"] == ["models:read"] + + def test_auth_jwks_path_bypasses_auth(self, auth_config_enabled): + app = FastAPI() + + @app.get("/apis/auth/jwks") + async def jwks(): + return {"keys": []} + + Configuration.set_override(auth_config_enabled) + app.add_middleware(AuthorizationMiddleware, service_name="test-service") + + client = TestClient(app, raise_server_exceptions=False) + with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize: + response = client.get("/apis/auth/jwks") + + assert response.status_code == 200 + mock_authorize.assert_not_called() + + def test_access_key_specific_jwks_path_is_not_a_health_bypass(self): + assert "/apis/auth/v2/access-keys/jwks" not in HEALTH_ENDPOINTS + + def test_authenticate_path_bypasses_auth(self, auth_config_enabled): + app = FastAPI() + + @app.post("/apis/auth/authenticate") + async def authenticate(): + return {"ok": True} + + Configuration.set_override(auth_config_enabled) + app.add_middleware(AuthorizationMiddleware, service_name="test-service") + + client = TestClient(app, raise_server_exceptions=False) + with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize: + response = client.post("/apis/auth/authenticate") + + assert response.status_code == 200 + mock_authorize.assert_not_called() + + def test_authenticate_prefixed_callout_path_bypasses_auth(self, auth_config_enabled): + app = FastAPI() + + @app.delete("/apis/auth/authenticate/apis/entities/v2/workspaces/default") + async def authenticate_prefixed(): + return {"ok": True} + + Configuration.set_override(auth_config_enabled) + app.add_middleware(AuthorizationMiddleware, service_name="test-service") + + client = TestClient(app, raise_server_exceptions=False) + with patch("nmp.common.auth.client.AuthClient.authorize_request") as mock_authorize: + response = client.delete("/apis/auth/authenticate/apis/entities/v2/workspaces/default") + + assert response.status_code == 200 + mock_authorize.assert_not_called() + class TestPrincipalHeadersAuth: """Tests for X-NMP-Principal-* header authentication.""" diff --git a/packages/nmp_common/tests/auth/test_signing_keys.py b/packages/nmp_common/tests/auth/test_signing_keys.py new file mode 100644 index 0000000000..c344f05fb9 --- /dev/null +++ b/packages/nmp_common/tests/auth/test_signing_keys.py @@ -0,0 +1,357 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path +from typing import Any + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from nmp.common.auth import signing_keys +from nmp.common.auth.signing_keys import RSASigningKeyCache + + +def _private_key_pem() -> bytes: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +def _write_private_key(path: Path) -> None: + path.write_bytes(_private_key_pem()) + + +def test_signing_key_cache_reads_private_key_file_once_for_same_file_metadata( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + key_path = tmp_path / "private.pem" + _write_private_key(key_path) + original_load = signing_keys._load_rsa_signing_key_async + load_count = 0 + + async def counted_load(**kwargs: Any) -> signing_keys.RSASigningKey: + nonlocal load_count + load_count += 1 + return await original_load(**kwargs) + + monkeypatch.setattr(signing_keys, "_load_rsa_signing_key_async", counted_load) + cache = RSASigningKeyCache() + + first = cache.get_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + second = cache.get_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + + assert first is second + assert load_count == 1 + + +def test_signing_key_cache_reloads_when_file_metadata_changes(tmp_path: Path) -> None: + key_path = tmp_path / "private.pem" + _write_private_key(key_path) + cache = RSASigningKeyCache() + + first = cache.get_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + _write_private_key(key_path) + stat = key_path.stat() + os.utime(key_path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000)) + second = cache.get_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + + assert second is not first + assert second.public_jwk["kid"] == "test-key" + assert second.public_jwk["n"] != first.public_jwk["n"] + + +def test_signing_key_cache_reloads_when_kid_changes(tmp_path: Path) -> None: + key_path = tmp_path / "private.pem" + _write_private_key(key_path) + cache = RSASigningKeyCache() + + first = cache.get_from_file( + kid="first-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + second = cache.get_from_file( + kid="second-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + + assert second is not first + assert first.public_jwk["kid"] == "first-key" + assert second.public_jwk["kid"] == "second-key" + + +def test_public_jwk_from_file_returns_copy(tmp_path: Path) -> None: + key_path = tmp_path / "private.pem" + _write_private_key(key_path) + cache = RSASigningKeyCache() + + first = cache.public_jwk_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + first["kid"] = "mutated" + second = cache.public_jwk_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + + assert second["kid"] == "test-key" + assert second["use"] == "sig" + assert second["alg"] == "RS256" + + +def test_signing_key_cache_rejects_missing_private_key_file() -> None: + cache = RSASigningKeyCache() + + with pytest.raises(RuntimeError, match="private key file is required"): + cache.get_from_file( + kid="test-key", + private_key_file=None, + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + + +def test_signing_key_cache_maps_missing_private_key_path_to_missing_message(tmp_path: Path) -> None: + cache = RSASigningKeyCache() + + with pytest.raises(RuntimeError, match="private key file is required"): + cache.get_from_file( + kid="test-key", + private_key_file=str(tmp_path / "missing.pem"), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + + +def test_signing_key_cache_maps_unreadable_private_key_file_to_missing_message( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + key_path = tmp_path / "private.pem" + _write_private_key(key_path) + + class UnreadableFile: + async def __aenter__(self) -> None: + raise PermissionError("permission denied") + + async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: + return False + + def unreadable_open(*args: object, **kwargs: object) -> UnreadableFile: + return UnreadableFile() + + monkeypatch.setattr(signing_keys.aiofiles, "open", unreadable_open) + cache = RSASigningKeyCache() + + with pytest.raises(RuntimeError, match="private key file is required"): + cache.get_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + + +def test_signing_key_cache_maps_malformed_private_key_to_invalid_message(tmp_path: Path) -> None: + key_path = tmp_path / "private.pem" + key_path.write_text("not a pem") + cache = RSASigningKeyCache() + + with pytest.raises(RuntimeError, match="private key must be RSA"): + cache.get_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + + +def test_signing_key_cache_maps_encrypted_private_key_to_invalid_message(tmp_path: Path) -> None: + key_path = tmp_path / "private.pem" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + key_path.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.BestAvailableEncryption(b"password"), + ) + ) + cache = RSASigningKeyCache() + + with pytest.raises(RuntimeError, match="private key must be RSA"): + cache.get_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + + +def test_signing_key_cache_rejects_non_rsa_key(tmp_path: Path) -> None: + from cryptography.hazmat.primitives.asymmetric import ed25519 + + key_path = tmp_path / "private.pem" + private_key = ed25519.Ed25519PrivateKey.generate() + key_path.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + cache = RSASigningKeyCache() + + with pytest.raises(RuntimeError, match="private key must be RSA"): + cache.get_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + + +def test_async_signing_key_cache_reads_private_key_file_once_for_same_file_metadata( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + key_path = tmp_path / "private.pem" + _write_private_key(key_path) + original_load = signing_keys._load_rsa_signing_key_async + load_count = 0 + + async def counted_load(**kwargs: Any) -> signing_keys.RSASigningKey: + nonlocal load_count + load_count += 1 + return await original_load(**kwargs) + + monkeypatch.setattr(signing_keys, "_load_rsa_signing_key_async", counted_load) + cache = RSASigningKeyCache() + + async_key = asyncio.run( + cache.get_from_file_async( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + ) + second_async_key = asyncio.run( + cache.get_from_file_async( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + ) + + assert second_async_key is async_key + assert load_count == 1 + + +def test_signing_key_cache_sync_and_async_use_same_cached_entry(tmp_path: Path) -> None: + key_path = tmp_path / "private.pem" + _write_private_key(key_path) + cache = RSASigningKeyCache() + + sync_key = cache.get_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + async_key = asyncio.run( + cache.get_from_file_async( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + ) + + assert async_key is sync_key + + +async def test_signing_key_cache_sync_wrapper_rejects_running_event_loop(tmp_path: Path) -> None: + key_path = tmp_path / "private.pem" + _write_private_key(key_path) + cache = RSASigningKeyCache() + + with pytest.raises(RuntimeError, match="Use RSASigningKeyCache async methods"): + cache.get_from_file( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + + +def test_concurrent_async_signing_key_requests_initialize_cache_once( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + key_path = tmp_path / "private.pem" + _write_private_key(key_path) + original_load = signing_keys._load_rsa_signing_key_async + load_count = 0 + + async def counted_load(**kwargs: Any) -> signing_keys.RSASigningKey: + nonlocal load_count + load_count += 1 + return await original_load(**kwargs) + + monkeypatch.setattr(signing_keys, "_load_rsa_signing_key_async", counted_load) + cache = RSASigningKeyCache() + + async def load_many() -> list[object]: + return await asyncio.gather( + *[ + cache.get_from_file_async( + kid="test-key", + private_key_file=str(key_path), + missing_private_key_message="private key file is required", + invalid_private_key_message="private key must be RSA", + ) + for _ in range(10) + ] + ) + + keys = asyncio.run(load_many()) + + assert all(key is keys[0] for key in keys) + assert load_count == 1 diff --git a/packages/nmp_common/tests/auth/test_token_resolver.py b/packages/nmp_common/tests/auth/test_token_resolver.py new file mode 100644 index 0000000000..b712c15db5 --- /dev/null +++ b/packages/nmp_common/tests/auth/test_token_resolver.py @@ -0,0 +1,111 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from nmp.common.auth.jwt import TokenClaims +from nmp.common.auth.token_resolver import ResolvedBearerToken, resolve_bearer_token +from nmp.common.config import AuthConfig +from nmp.common.config.base import AccessKeyConfig, OIDCConfig + + +def _claims(subject: str = "alice@example.com") -> TokenClaims: + return TokenClaims( + subject=subject, + email=subject, + groups=["team-ml"], + scopes=["models:read"], + raw_claims={"jti": "ak_example"}, + ) + + +@pytest.mark.asyncio +async def test_resolver_skips_access_key_validator_when_access_keys_are_disabled() -> None: + config = AuthConfig( + enabled=True, + access_keys=AccessKeyConfig(enabled=False), + oidc=OIDCConfig(enabled=False), + ) + + with patch("nmp.common.auth.access_keys.validate_access_key_token") as validate_access_key: + resolved = await resolve_bearer_token(config, "bearer-token") + + assert resolved is None + validate_access_key.assert_not_called() + + +@pytest.mark.asyncio +async def test_resolver_uses_access_key_validator_when_access_keys_are_enabled() -> None: + config = AuthConfig( + enabled=True, + access_keys=AccessKeyConfig(enabled=True), + oidc=OIDCConfig(enabled=False), + ) + claims = _claims() + + with patch( + "nmp.common.auth.access_keys.validate_access_key_token", + new=AsyncMock(return_value=claims), + ) as validate_access_key: + resolved = await resolve_bearer_token(config, "scoped-access-key") + + assert resolved == ResolvedBearerToken(claims=claims, token_kind="access_key") + assert resolved is not None + assert resolved.principal.id == "alice@example.com" + assert resolved.principal_headers() == { + "X-NMP-Principal-Id": "alice@example.com", + "X-NMP-Principal-Email": "alice@example.com", + "X-NMP-Principal-Groups": "team-ml", + "X-NMP-Scopes": "models:read", + } + validate_access_key.assert_awaited_once_with(config, "scoped-access-key") + + +@pytest.mark.asyncio +async def test_resolver_uses_extra_resolvers_before_oidc() -> None: + config = AuthConfig( + enabled=True, + access_keys=AccessKeyConfig(enabled=False), + oidc=OIDCConfig(enabled=True, issuer="https://sso.example.com", client_id="nemo-platform-cli"), + ) + workload_claims = _claims("system:serviceaccount:nemo:job") + extra_resolver = AsyncMock( + return_value=ResolvedBearerToken( + claims=workload_claims, + token_kind="workload_access_token", + ) + ) + jwt_validator = MagicMock() + + resolved = await resolve_bearer_token( + config, + "workload-token", + jwt_validator=jwt_validator, + extra_resolvers=[extra_resolver], + ) + + assert resolved == ResolvedBearerToken(claims=workload_claims, token_kind="workload_access_token") + extra_resolver.assert_awaited_once_with("workload-token") + jwt_validator.validate_token.assert_not_called() + + +@pytest.mark.asyncio +async def test_resolver_falls_back_to_oidc_validator() -> None: + config = AuthConfig( + enabled=True, + access_keys=AccessKeyConfig(enabled=True), + oidc=OIDCConfig(enabled=True, issuer="https://sso.example.com", client_id="nemo-platform-cli"), + ) + oidc_claims = _claims("bob@example.com") + jwt_validator = MagicMock() + jwt_validator.validate_token = AsyncMock(return_value=oidc_claims) + + with patch( + "nmp.common.auth.access_keys.validate_access_key_token", + new=AsyncMock(return_value=None), + ): + resolved = await resolve_bearer_token(config, "oidc-token", jwt_validator=jwt_validator) + + assert resolved == ResolvedBearerToken(claims=oidc_claims, token_kind="oidc_access_token") + jwt_validator.validate_token.assert_awaited_once_with("oidc-token") diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml index 359cbae96a..8be64a3de8 100644 --- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml @@ -4,6 +4,55 @@ info: description: API for Nemo Platform services version: 0.0.0 paths: + /apis/auth/authenticate: + get: + tags: + - Authentication + summary: Authenticate Bearer Token Get + operationId: get_authenticate_bearer_token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateResponse' + '401': + description: Missing or invalid bearer token + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + '500': + description: Bearer token authentication is misconfigured + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + post: + tags: + - Authentication + summary: Authenticate Bearer Token Post + operationId: post_authenticate_bearer_token + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateResponse' + '401': + description: Missing or invalid bearer token + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' + '500': + description: Bearer token authentication is misconfigured + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticateErrorResponse' /apis/auth/discovery: get: tags: @@ -117,6 +166,110 @@ paths: application/json: schema: $ref: '#/components/schemas/WorkloadTokenExchangeErrorResponse' + /apis/auth/v2/access-keys: + get: + tags: + - Scoped Access Keys + summary: List Access Keys + operationId: list_access_keys_apis_auth_v2_access_keys_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyListResponse' + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + post: + tags: + - Scoped Access Keys + summary: Create Access Key + operationId: create_access_key_apis_auth_v2_access_keys_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyCreateResponse' + '400': + description: Scoped Access Key creation error + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /apis/auth/v2/access-keys/{jti}: + delete: + tags: + - Scoped Access Keys + summary: Revoke Access Key + operationId: revoke_access_key_apis_auth_v2_access_keys__jti__delete + parameters: + - name: jti + in: path + required: true + schema: + type: string + title: Jti + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '404': + description: Scoped Access Keys are not enabled + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyErrorResponse' + '501': + description: Not Implemented + content: + application/json: + schema: + $ref: '#/components/schemas/AccessKeyNotImplementedErrorResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /apis/auth/v2/iam/role-bindings: get: tags: @@ -7764,6 +7917,124 @@ components: type: object title: APIEndpointData description: Data about an inference endpoint. + AccessKeyCreateRequest: + properties: + name: + title: Name + description: Optional human-readable Scoped Access Key label. The token + jti remains the stable identifier. + type: string + maxLength: 128 + minLength: 1 + expires_in_seconds: + title: Expires In Seconds + description: Scoped Access Key lifetime in seconds. Omit to use auth.access_keys.default_expires_in_seconds. + Send explicit null to request a non-time-delimited key, which requires + auth.access_keys.max_expires_in_seconds to be disabled. + type: integer + minimum: 1.0 + type: object + title: AccessKeyCreateRequest + description: Request body for creating a Scoped Access Key. + AccessKeyCreateResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + name: + title: Name + description: Optional human-readable Scoped Access Key label. + type: string + principal: + type: string + title: Principal + description: Principal ID stamped into the token. + created_at: + type: string + format: date-time + title: Created At + expires_at: + title: Expires At + type: string + format: date-time + token: + type: string + title: Token + token_type: + type: string + const: Bearer + title: Token Type + type: object + required: + - jti + - principal + - created_at + - token + - token_type + title: AccessKeyCreateResponse + description: Create response. The token value is returned only once. + AccessKeyErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AccessKeyErrorResponse + description: Scoped Access Key error response. + AccessKeyListResponse: + properties: + data: + items: + $ref: '#/components/schemas/AccessKeyMetadataResponse' + type: array + title: Data + type: object + required: + - data + title: AccessKeyListResponse + description: List response for Scoped Access Key metadata. + AccessKeyMetadataResponse: + properties: + jti: + type: string + title: Jti + description: Stable JWT ID for this Scoped Access Key. + name: + title: Name + description: Optional human-readable Scoped Access Key label. + type: string + principal: + type: string + title: Principal + description: Principal ID stamped into the token. + created_at: + type: string + format: date-time + title: Created At + expires_at: + title: Expires At + type: string + format: date-time + type: object + required: + - jti + - principal + - created_at + title: AccessKeyMetadataResponse + description: Metadata for a Scoped Access Key. + AccessKeyNotImplementedErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AccessKeyNotImplementedErrorResponse + description: Response returned by unsupported Scoped Access Key lifecycle endpoints. ActionRails: properties: instant_actions: @@ -8627,6 +8898,53 @@ components: - auth_enabled title: AuthDiscoveryResponse description: Auth discovery response for CLI/SDK. + AuthenticateErrorResponse: + properties: + detail: + type: string + title: Detail + type: object + required: + - detail + title: AuthenticateErrorResponse + description: Bearer token authentication error response. + AuthenticateResponse: + properties: + principal: + type: string + title: Principal + email: + title: Email + nullable: true + type: string + groups: + items: + type: string + type: array + title: Groups + scopes: + items: + type: string + type: array + title: Scopes + jti: + title: Jti + nullable: true + type: string + token_kind: + type: string + enum: + - access_key + - oidc_access_token + - workload_access_token + - workload_subject_token + title: Token Kind + type: object + required: + - principal + - token_kind + title: AuthenticateResponse + description: Successful bearer token authentication response for auth callouts. AutoAlignOptions: properties: guardrails_config: diff --git a/sdk/python/nemo-platform/.nmpcontext/stainless.yaml b/sdk/python/nemo-platform/.nmpcontext/stainless.yaml index 02a0898d9d..b33b006b48 100644 --- a/sdk/python/nemo-platform/.nmpcontext/stainless.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/stainless.yaml @@ -953,3 +953,24 @@ resources: retrieve: get /apis/intake/v2/workspaces/{workspace}/experiments/{name} update: put /apis/intake/v2/workspaces/{workspace}/experiments/{name} delete: delete /apis/intake/v2/workspaces/{workspace}/experiments/{name} + auth: + standalone_api: true + models: + authenticate_error_response: AuthenticateErrorResponse + authenticate_response: AuthenticateResponse + methods: + authenticate_get: get /apis/auth/authenticate + authenticate: post /apis/auth/authenticate + access_keys: + standalone_api: true + models: + access_key_create_request: AccessKeyCreateRequest + access_key_create_response: AccessKeyCreateResponse + access_key_error_response: AccessKeyErrorResponse + access_key_list_response: AccessKeyListResponse + access_key_metadata_response: AccessKeyMetadataResponse + access_key_not_implemented_error_response: AccessKeyNotImplementedErrorResponse + methods: + list: get /apis/auth/v2/access-keys + create: post /apis/auth/v2/access-keys + delete: delete /apis/auth/v2/access-keys/{jti} diff --git a/sdk/python/nemo-platform/api.md b/sdk/python/nemo-platform/api.md index b291b7143b..caa0a29015 100644 --- a/sdk/python/nemo-platform/api.md +++ b/sdk/python/nemo-platform/api.md @@ -75,3 +75,7 @@ from nemo_platform.types import ( # [Evaluations](src/nemo_platform/resources/evaluations/api.md) # [Experiments](src/nemo_platform/resources/experiments/api.md) + +# [Auth](src/nemo_platform/resources/auth/api.md) + +# [AccessKeys](src/nemo_platform/resources/access_keys/api.md) diff --git a/sdk/python/nemo-platform/src/nemo_platform/_client.py b/sdk/python/nemo-platform/src/nemo_platform/_client.py index bb6f751eb0..9c5761f202 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/_client.py +++ b/sdk/python/nemo-platform/src/nemo_platform/_client.py @@ -56,6 +56,7 @@ if TYPE_CHECKING: from .resources import ( iam, + auth, jobs, files, intake, @@ -67,10 +68,12 @@ guardrail, inference, workspaces, + access_keys, evaluations, experiments, ) from .resources.iam.iam import IamResource, AsyncIamResource + from .resources.auth.auth import AuthResource, AsyncAuthResource from .resources.jobs.jobs import JobsResource, AsyncJobsResource from .filesets.resources import FilesResource, AsyncFilesResource from .resources.intake.intake import IntakeResource, AsyncIntakeResource @@ -82,6 +85,7 @@ from .resources.guardrail.guardrail import GuardrailResource, AsyncGuardrailResource from .resources.inference.inference import InferenceResource, AsyncInferenceResource from .resources.workspaces.workspaces import WorkspacesResource, AsyncWorkspacesResource + from .resources.access_keys.access_keys import AccessKeysResource, AsyncAccessKeysResource from .resources.evaluations.evaluations import EvaluationsResource, AsyncEvaluationsResource from .resources.experiments.experiments import ExperimentsResource, AsyncExperimentsResource @@ -336,6 +340,18 @@ def experiments(self) -> ExperimentsResource: return ExperimentsResource(self) + @cached_property + def auth(self) -> AuthResource: + from .resources.auth import AuthResource + + return AuthResource(self) + + @cached_property + def access_keys(self) -> AccessKeysResource: + from .resources.access_keys import AccessKeysResource + + return AccessKeysResource(self) + @cached_property def with_raw_response(self) -> NeMoPlatformWithRawResponse: return NeMoPlatformWithRawResponse(self) @@ -700,6 +716,18 @@ def experiments(self) -> AsyncExperimentsResource: return AsyncExperimentsResource(self) + @cached_property + def auth(self) -> AsyncAuthResource: + from .resources.auth import AsyncAuthResource + + return AsyncAuthResource(self) + + @cached_property + def access_keys(self) -> AsyncAccessKeysResource: + from .resources.access_keys import AsyncAccessKeysResource + + return AsyncAccessKeysResource(self) + @cached_property def with_raw_response(self) -> AsyncNeMoPlatformWithRawResponse: return AsyncNeMoPlatformWithRawResponse(self) @@ -921,6 +949,18 @@ def experiments(self) -> experiments.ExperimentsResourceWithRawResponse: return ExperimentsResourceWithRawResponse(self._client.experiments) + @cached_property + def auth(self) -> auth.AuthResourceWithRawResponse: + from .resources.auth import AuthResourceWithRawResponse + + return AuthResourceWithRawResponse(self._client.auth) + + @cached_property + def access_keys(self) -> access_keys.AccessKeysResourceWithRawResponse: + from .resources.access_keys import AccessKeysResourceWithRawResponse + + return AccessKeysResourceWithRawResponse(self._client.access_keys) + class AsyncNeMoPlatformWithRawResponse: _client: AsyncNeMoPlatform @@ -1012,6 +1052,18 @@ def experiments(self) -> experiments.AsyncExperimentsResourceWithRawResponse: return AsyncExperimentsResourceWithRawResponse(self._client.experiments) + @cached_property + def auth(self) -> auth.AsyncAuthResourceWithRawResponse: + from .resources.auth import AsyncAuthResourceWithRawResponse + + return AsyncAuthResourceWithRawResponse(self._client.auth) + + @cached_property + def access_keys(self) -> access_keys.AsyncAccessKeysResourceWithRawResponse: + from .resources.access_keys import AsyncAccessKeysResourceWithRawResponse + + return AsyncAccessKeysResourceWithRawResponse(self._client.access_keys) + class NeMoPlatformWithStreamedResponse: _client: NeMoPlatform @@ -1103,6 +1155,18 @@ def experiments(self) -> experiments.ExperimentsResourceWithStreamingResponse: return ExperimentsResourceWithStreamingResponse(self._client.experiments) + @cached_property + def auth(self) -> auth.AuthResourceWithStreamingResponse: + from .resources.auth import AuthResourceWithStreamingResponse + + return AuthResourceWithStreamingResponse(self._client.auth) + + @cached_property + def access_keys(self) -> access_keys.AccessKeysResourceWithStreamingResponse: + from .resources.access_keys import AccessKeysResourceWithStreamingResponse + + return AccessKeysResourceWithStreamingResponse(self._client.access_keys) + class AsyncNeMoPlatformWithStreamedResponse: _client: AsyncNeMoPlatform @@ -1194,6 +1258,18 @@ def experiments(self) -> experiments.AsyncExperimentsResourceWithStreamingRespon return AsyncExperimentsResourceWithStreamingResponse(self._client.experiments) + @cached_property + def auth(self) -> auth.AsyncAuthResourceWithStreamingResponse: + from .resources.auth import AsyncAuthResourceWithStreamingResponse + + return AsyncAuthResourceWithStreamingResponse(self._client.auth) + + @cached_property + def access_keys(self) -> access_keys.AsyncAccessKeysResourceWithStreamingResponse: + from .resources.access_keys import AsyncAccessKeysResourceWithStreamingResponse + + return AsyncAccessKeysResourceWithStreamingResponse(self._client.access_keys) + Client = NeMoPlatform diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py index b8b04cff48..e2b7abf350 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/auth.py @@ -10,13 +10,21 @@ from __future__ import annotations import asyncio +import json import logging import os import time -from typing import Annotated, cast +from typing import Annotated, NoReturn, cast import httpx import typer +from nemo_platform_plugin.auth.access_keys.client import AccessKeyIssuerClient, AccessKeysClient +from nemo_platform_plugin.auth.access_keys.issuer import ( + AccessKeyFeatureDisabledError, + AccessKeyOperationNotImplementedError, +) +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest +from nemo_platform_plugin.client.adapter import client_from_platform from rich.console import Console from nemo_platform.auth.helpers import ( @@ -40,10 +48,47 @@ name="auth", help="Manage authentication for NeMo Platform.", ) +access_keys_app = create_typer_app( + name="access-keys", + help="Manage NeMo Platform Scoped Access Keys.", +) +app.add_typer(access_keys_app, name="access-keys") logger = logging.getLogger(__name__) +def _access_key_issuer(ctx: typer.Context) -> AccessKeyIssuerClient: + state: CLIContext = ctx.obj + access_keys_client = client_from_platform(state.get_client(), AccessKeysClient) + return AccessKeyIssuerClient(access_keys_client) + + +def _raise_access_key_not_implemented(exc: AccessKeyOperationNotImplementedError) -> NoReturn: + raise AuthError(str(exc) or "Scoped Access Key operation is not implemented.") from exc + + +def _raise_access_key_disabled(exc: AccessKeyFeatureDisabledError) -> NoReturn: + raise AuthError(str(exc) or "Scoped Access Keys are not enabled.") from exc + + +def _parse_access_key_expires_in(value: str | None) -> tuple[bool, int | None]: + if value is None: + return False, None + + normalized = value.strip().lower() + if normalized in {"none", "null"}: + return True, None + + try: + expires_in_seconds = int(value) + except ValueError as exc: + raise AuthError("--expires-in must be a positive integer number of seconds or 'none'.") from exc + + if expires_in_seconds < 1: + raise AuthError("--expires-in must be a positive integer number of seconds or 'none'.") + return True, expires_in_seconds + + def is_auth_disabled(base_url: str, timeout: float = 3.0) -> bool: """Check whether authentication is disabled on the cluster. @@ -697,7 +742,7 @@ def refresh(ctx: typer.Context) -> None: try: oidc_config = discover_nmp_config(base_url) except httpx.HTTPError as e: - raise AuthError(f"Failed to discover auth configuration: {e}") + raise AuthError(f"Failed to discover auth configuration: {e}") from e if not oidc_config.client_id or not oidc_config.token_endpoint: raise AuthError("OIDC not configured on cluster.") @@ -741,14 +786,25 @@ def refresh(ctx: typer.Context) -> None: @app.command("token") @handle_errors -def token(ctx: typer.Context) -> None: +def token( + ctx: typer.Context, + decode: Annotated[ + bool, + typer.Option( + "--decode", + help="Decode the JWT payload claims as JSON. This does not verify the token signature.", + ), + ] = False, +) -> None: """Print the current access token (for use with SDK or curl). - This outputs the raw token to stdout, suitable for piping or capture. + By default this outputs the raw token to stdout, suitable for piping or capture. Examples: # Print token nemo auth token + # Inspect token claims + nemo auth token --decode # Capture in env var export TOKEN=$(nemo auth token) curl -H "Authorization: Bearer $(nemo auth token)" ... @@ -762,11 +818,49 @@ def token(ctx: typer.Context) -> None: raise AuthError("No authentication configured. Run 'nemo auth login' first.") if isinstance(context.user, OAuthUser): - typer.echo(context.user.token.get_secret_value()) + access_token = context.user.token.get_secret_value() + if decode: + claims = decode_jwt_claims(access_token) + if not claims: + raise AuthError("Current access token is not a decodable JWT.") + typer.echo(json.dumps(claims, indent=2)) + return + typer.echo(access_token) else: raise AuthError("No token available for current user type.") +@access_keys_app.command("create") +@handle_errors +def create_access_key( + ctx: typer.Context, + name: Annotated[ + str | None, + typer.Option("--name", "-n", help="Optional human-readable label for the Scoped Access Key."), + ] = None, + expires_in: Annotated[ + str | None, + typer.Option( + "--expires-in", + help="Scoped Access Key lifetime in seconds. Use 'none' to request no expiration.", + ), + ] = None, +) -> None: + """Create a Scoped Access Key for the current authenticated user.""" + expires_in_was_set, parsed_expires_in = _parse_access_key_expires_in(expires_in) + if expires_in_was_set: + request = AccessKeyCreateRequest(name=name, expires_in_seconds=parsed_expires_in) + else: + request = AccessKeyCreateRequest(name=name) + try: + created = _access_key_issuer(ctx).create(request) + except AccessKeyFeatureDisabledError as exc: + _raise_access_key_disabled(exc) + except AccessKeyOperationNotImplementedError as exc: + _raise_access_key_not_implemented(exc) + typer.echo(created.token) + + @app.command("status") @handle_errors def status(ctx: typer.Context) -> None: diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py index 2e078d4fc2..88a718a0df 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/manifest_registry.py @@ -12,7 +12,6 @@ name="auth", panel="Setup", kind="group", - hidden=True, ), TopLevelEntry( import_path="nemo_platform.cli.commands.config:app", diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/manifest.py b/sdk/python/nemo-platform/src/nemo_platform/cli/manifest.py index bc691fd299..7a2da98a76 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/manifest.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/manifest.py @@ -30,7 +30,7 @@ } TOP_LEVEL_COMMAND_ORDER: dict[PanelName, tuple[str, ...]] = { - "Setup": ("setup", "services", "skills"), + "Setup": ("setup", "auth", "services", "skills"), "CLI functions": ("chat", "docs", "wait", "agent", "plugins"), "Core plugins": ("files", "inference", "jobs", "models", "secrets", "workspaces"), "Functional plugins": ("agents", "data-designer", "guardrail", "audit", "anonymizer", "evaluator"), diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/__init__.py b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/__init__.py new file mode 100644 index 0000000000..01f412b4f5 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/__init__.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .access_keys import ( + AccessKeysResource, + AsyncAccessKeysResource, + AccessKeysResourceWithRawResponse, + AsyncAccessKeysResourceWithRawResponse, + AccessKeysResourceWithStreamingResponse, + AsyncAccessKeysResourceWithStreamingResponse, +) + +__all__ = [ + "AccessKeysResource", + "AsyncAccessKeysResource", + "AccessKeysResourceWithRawResponse", + "AsyncAccessKeysResourceWithRawResponse", + "AccessKeysResourceWithStreamingResponse", + "AsyncAccessKeysResourceWithStreamingResponse", +] diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/access_keys.py b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/access_keys.py new file mode 100644 index 0000000000..7b6251dfc3 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/access_keys.py @@ -0,0 +1,337 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import path_template, maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.access_keys import access_key_create_params +from ...types.access_keys.access_key_list_response import AccessKeyListResponse +from ...types.access_keys.access_key_create_response import AccessKeyCreateResponse + +__all__ = ["AccessKeysResource", "AsyncAccessKeysResource"] + + +class AccessKeysResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> AccessKeysResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#accessing-raw-response-data-e-g-headers + """ + return AccessKeysResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AccessKeysResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#with_streaming_response + """ + return AccessKeysResourceWithStreamingResponse(self) + + def create( + self, + *, + expires_in_seconds: int | Omit = omit, + name: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccessKeyCreateResponse: + """ + Create Access Key + + Args: + expires_in_seconds: Scoped Access Key lifetime in seconds. Omit to use + auth.access_keys.default_expires_in_seconds. Send explicit null to request a + non-time-delimited key, which requires auth.access_keys.max_expires_in_seconds + to be disabled. + + name: Optional human-readable Scoped Access Key label. The token jti remains the + stable identifier. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/apis/auth/v2/access-keys", + body=maybe_transform( + { + "expires_in_seconds": expires_in_seconds, + "name": name, + }, + access_key_create_params.AccessKeyCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccessKeyCreateResponse, + ) + + def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccessKeyListResponse: + """List Access Keys""" + return self._get( + "/apis/auth/v2/access-keys", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccessKeyListResponse, + ) + + def delete( + self, + jti: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> object: + """ + Revoke Access Key + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not jti: + raise ValueError(f"Expected a non-empty value for `jti` but received {jti!r}") + return self._delete( + path_template("/apis/auth/v2/access-keys/{jti}", jti=jti), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=object, + ) + + +class AsyncAccessKeysResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncAccessKeysResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#accessing-raw-response-data-e-g-headers + """ + return AsyncAccessKeysResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAccessKeysResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#with_streaming_response + """ + return AsyncAccessKeysResourceWithStreamingResponse(self) + + async def create( + self, + *, + expires_in_seconds: int | Omit = omit, + name: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccessKeyCreateResponse: + """ + Create Access Key + + Args: + expires_in_seconds: Scoped Access Key lifetime in seconds. Omit to use + auth.access_keys.default_expires_in_seconds. Send explicit null to request a + non-time-delimited key, which requires auth.access_keys.max_expires_in_seconds + to be disabled. + + name: Optional human-readable Scoped Access Key label. The token jti remains the + stable identifier. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/apis/auth/v2/access-keys", + body=await async_maybe_transform( + { + "expires_in_seconds": expires_in_seconds, + "name": name, + }, + access_key_create_params.AccessKeyCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccessKeyCreateResponse, + ) + + async def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AccessKeyListResponse: + """List Access Keys""" + return await self._get( + "/apis/auth/v2/access-keys", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AccessKeyListResponse, + ) + + async def delete( + self, + jti: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> object: + """ + Revoke Access Key + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not jti: + raise ValueError(f"Expected a non-empty value for `jti` but received {jti!r}") + return await self._delete( + path_template("/apis/auth/v2/access-keys/{jti}", jti=jti), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=object, + ) + + +class AccessKeysResourceWithRawResponse: + def __init__(self, access_keys: AccessKeysResource) -> None: + self._access_keys = access_keys + + self.create = to_raw_response_wrapper( + access_keys.create, + ) + self.list = to_raw_response_wrapper( + access_keys.list, + ) + self.delete = to_raw_response_wrapper( + access_keys.delete, + ) + + +class AsyncAccessKeysResourceWithRawResponse: + def __init__(self, access_keys: AsyncAccessKeysResource) -> None: + self._access_keys = access_keys + + self.create = async_to_raw_response_wrapper( + access_keys.create, + ) + self.list = async_to_raw_response_wrapper( + access_keys.list, + ) + self.delete = async_to_raw_response_wrapper( + access_keys.delete, + ) + + +class AccessKeysResourceWithStreamingResponse: + def __init__(self, access_keys: AccessKeysResource) -> None: + self._access_keys = access_keys + + self.create = to_streamed_response_wrapper( + access_keys.create, + ) + self.list = to_streamed_response_wrapper( + access_keys.list, + ) + self.delete = to_streamed_response_wrapper( + access_keys.delete, + ) + + +class AsyncAccessKeysResourceWithStreamingResponse: + def __init__(self, access_keys: AsyncAccessKeysResource) -> None: + self._access_keys = access_keys + + self.create = async_to_streamed_response_wrapper( + access_keys.create, + ) + self.list = async_to_streamed_response_wrapper( + access_keys.list, + ) + self.delete = async_to_streamed_response_wrapper( + access_keys.delete, + ) diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/api.md new file mode 100644 index 0000000000..b42a03a46b --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/access_keys/api.md @@ -0,0 +1,20 @@ +# AccessKeys + +Types: + +```python +from nemo_platform.types.access_keys import ( + AccessKeyCreateRequest, + AccessKeyCreateResponse, + AccessKeyErrorResponse, + AccessKeyListResponse, + AccessKeyMetadataResponse, + AccessKeyNotImplementedErrorResponse, +) +``` + +Methods: + +- client.access_keys.create(\*\*params) -> AccessKeyCreateResponse +- client.access_keys.list() -> AccessKeyListResponse +- client.access_keys.delete(jti) -> object diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/auth/__init__.py b/sdk/python/nemo-platform/src/nemo_platform/resources/auth/__init__.py new file mode 100644 index 0000000000..de0d131df5 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/auth/__init__.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from .auth import ( + AuthResource, + AsyncAuthResource, + AuthResourceWithRawResponse, + AsyncAuthResourceWithRawResponse, + AuthResourceWithStreamingResponse, + AsyncAuthResourceWithStreamingResponse, +) + +__all__ = [ + "AuthResource", + "AsyncAuthResource", + "AuthResourceWithRawResponse", + "AsyncAuthResourceWithRawResponse", + "AuthResourceWithStreamingResponse", + "AsyncAuthResourceWithStreamingResponse", +] diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/auth/api.md b/sdk/python/nemo-platform/src/nemo_platform/resources/auth/api.md new file mode 100644 index 0000000000..47bf46c9d7 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/auth/api.md @@ -0,0 +1,12 @@ +# Auth + +Types: + +```python +from nemo_platform.types.auth import AuthenticateErrorResponse, AuthenticateResponse +``` + +Methods: + +- client.auth.authenticate() -> AuthenticateResponse +- client.auth.authenticate_get() -> AuthenticateResponse diff --git a/sdk/python/nemo-platform/src/nemo_platform/resources/auth/auth.py b/sdk/python/nemo-platform/src/nemo_platform/resources/auth/auth.py new file mode 100644 index 0000000000..202000cb9b --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/resources/auth/auth.py @@ -0,0 +1,200 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.auth.authenticate_response import AuthenticateResponse + +__all__ = ["AuthResource", "AsyncAuthResource"] + + +class AuthResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> AuthResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#accessing-raw-response-data-e-g-headers + """ + return AuthResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AuthResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#with_streaming_response + """ + return AuthResourceWithStreamingResponse(self) + + def authenticate( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AuthenticateResponse: + """Authenticate Bearer Token Post""" + return self._post( + "/apis/auth/authenticate", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AuthenticateResponse, + ) + + def authenticate_get( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AuthenticateResponse: + """Authenticate Bearer Token Get""" + return self._get( + "/apis/auth/authenticate", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AuthenticateResponse, + ) + + +class AsyncAuthResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncAuthResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#accessing-raw-response-data-e-g-headers + """ + return AsyncAuthResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAuthResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://docs.nvidia.com/nemo/microservices/latest/pysdk/index.html#with_streaming_response + """ + return AsyncAuthResourceWithStreamingResponse(self) + + async def authenticate( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AuthenticateResponse: + """Authenticate Bearer Token Post""" + return await self._post( + "/apis/auth/authenticate", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AuthenticateResponse, + ) + + async def authenticate_get( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AuthenticateResponse: + """Authenticate Bearer Token Get""" + return await self._get( + "/apis/auth/authenticate", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AuthenticateResponse, + ) + + +class AuthResourceWithRawResponse: + def __init__(self, auth: AuthResource) -> None: + self._auth = auth + + self.authenticate = to_raw_response_wrapper( + auth.authenticate, + ) + self.authenticate_get = to_raw_response_wrapper( + auth.authenticate_get, + ) + + +class AsyncAuthResourceWithRawResponse: + def __init__(self, auth: AsyncAuthResource) -> None: + self._auth = auth + + self.authenticate = async_to_raw_response_wrapper( + auth.authenticate, + ) + self.authenticate_get = async_to_raw_response_wrapper( + auth.authenticate_get, + ) + + +class AuthResourceWithStreamingResponse: + def __init__(self, auth: AuthResource) -> None: + self._auth = auth + + self.authenticate = to_streamed_response_wrapper( + auth.authenticate, + ) + self.authenticate_get = to_streamed_response_wrapper( + auth.authenticate_get, + ) + + +class AsyncAuthResourceWithStreamingResponse: + def __init__(self, auth: AsyncAuthResource) -> None: + self._auth = auth + + self.authenticate = async_to_streamed_response_wrapper( + auth.authenticate, + ) + self.authenticate_get = async_to_streamed_response_wrapper( + auth.authenticate_get, + ) diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/__init__.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/__init__.py new file mode 100644 index 0000000000..6bf30f77aa --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/__init__.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .access_key_create_params import AccessKeyCreateParams as AccessKeyCreateParams +from .access_key_list_response import AccessKeyListResponse as AccessKeyListResponse +from .access_key_create_response import AccessKeyCreateResponse as AccessKeyCreateResponse +from .access_key_metadata_response import AccessKeyMetadataResponse as AccessKeyMetadataResponse diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_params.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_params.py new file mode 100644 index 0000000000..e85eeb17c2 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_params.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["AccessKeyCreateParams"] + + +class AccessKeyCreateParams(TypedDict, total=False): + expires_in_seconds: int + """Scoped Access Key lifetime in seconds. + + Omit to use auth.access_keys.default_expires_in_seconds. Send explicit null to + request a non-time-delimited key, which requires + auth.access_keys.max_expires_in_seconds to be disabled. + """ + + name: str + """Optional human-readable Scoped Access Key label. + + The token jti remains the stable identifier. + """ diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_response.py new file mode 100644 index 0000000000..5efb4f7c07 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_create_response.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["AccessKeyCreateResponse"] + + +class AccessKeyCreateResponse(BaseModel): + """Create response. The token value is returned only once.""" + + token: str + + created_at: datetime + + jti: str + """Stable JWT ID for this Scoped Access Key.""" + + principal: str + """Principal ID stamped into the token.""" + + token_type: Literal["Bearer"] + + expires_at: Optional[datetime] = None + + name: Optional[str] = None + """Optional human-readable Scoped Access Key label.""" diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_response.py new file mode 100644 index 0000000000..12375871c6 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_list_response.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from ..._models import BaseModel +from .access_key_metadata_response import AccessKeyMetadataResponse + +__all__ = ["AccessKeyListResponse"] + + +class AccessKeyListResponse(BaseModel): + """List response for Scoped Access Key metadata.""" + + data: List[AccessKeyMetadataResponse] diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_metadata_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_metadata_response.py new file mode 100644 index 0000000000..969979365d --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/types/access_keys/access_key_metadata_response.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime + +from ..._models import BaseModel + +__all__ = ["AccessKeyMetadataResponse"] + + +class AccessKeyMetadataResponse(BaseModel): + """Metadata for a Scoped Access Key.""" + + created_at: datetime + + jti: str + """Stable JWT ID for this Scoped Access Key.""" + + principal: str + """Principal ID stamped into the token.""" + + expires_at: Optional[datetime] = None + + name: Optional[str] = None + """Optional human-readable Scoped Access Key label.""" diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/auth/__init__.py b/sdk/python/nemo-platform/src/nemo_platform/types/auth/__init__.py new file mode 100644 index 0000000000..6250f8c894 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/types/auth/__init__.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from .authenticate_response import AuthenticateResponse as AuthenticateResponse diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/auth/authenticate_response.py b/sdk/python/nemo-platform/src/nemo_platform/types/auth/authenticate_response.py new file mode 100644 index 0000000000..080364ad37 --- /dev/null +++ b/sdk/python/nemo-platform/src/nemo_platform/types/auth/authenticate_response.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["AuthenticateResponse"] + + +class AuthenticateResponse(BaseModel): + """Successful bearer token authentication response for auth callouts.""" + + principal: str + + token_kind: Literal["access_key", "oidc_access_token", "workload_access_token", "workload_subject_token"] + + email: Optional[str] = None + + groups: Optional[List[str]] = None + + jti: Optional[str] = None + + scopes: Optional[List[str]] = None diff --git a/sdk/python/nemo-platform/tests/api_resources/access_keys/__init__.py b/sdk/python/nemo-platform/tests/api_resources/access_keys/__init__.py new file mode 100644 index 0000000000..92808494e3 --- /dev/null +++ b/sdk/python/nemo-platform/tests/api_resources/access_keys/__init__.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/sdk/python/nemo-platform/tests/api_resources/auth/__init__.py b/sdk/python/nemo-platform/tests/api_resources/auth/__init__.py new file mode 100644 index 0000000000..92808494e3 --- /dev/null +++ b/sdk/python/nemo-platform/tests/api_resources/auth/__init__.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. diff --git a/sdk/python/nemo-platform/tests/api_resources/test_access_keys.py b/sdk/python/nemo-platform/tests/api_resources/test_access_keys.py new file mode 100644 index 0000000000..18679bed66 --- /dev/null +++ b/sdk/python/nemo-platform/tests/api_resources/test_access_keys.py @@ -0,0 +1,253 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from nemo_platform import NeMoPlatform, AsyncNeMoPlatform +from nemo_platform.types.access_keys import AccessKeyListResponse, AccessKeyCreateResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAccessKeys: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create(self, client: NeMoPlatform) -> None: + access_key = client.access_keys.create() + assert_matches_type(AccessKeyCreateResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_create_with_all_params(self, client: NeMoPlatform) -> None: + access_key = client.access_keys.create( + expires_in_seconds=1, + name="x", + ) + assert_matches_type(AccessKeyCreateResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_create(self, client: NeMoPlatform) -> None: + response = client.access_keys.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + access_key = response.parse() + assert_matches_type(AccessKeyCreateResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_create(self, client: NeMoPlatform) -> None: + with client.access_keys.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + access_key = response.parse() + assert_matches_type(AccessKeyCreateResponse, access_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: NeMoPlatform) -> None: + access_key = client.access_keys.list() + assert_matches_type(AccessKeyListResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: NeMoPlatform) -> None: + response = client.access_keys.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + access_key = response.parse() + assert_matches_type(AccessKeyListResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: NeMoPlatform) -> None: + with client.access_keys.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + access_key = response.parse() + assert_matches_type(AccessKeyListResponse, access_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_delete(self, client: NeMoPlatform) -> None: + access_key = client.access_keys.delete( + "jti", + ) + assert_matches_type(object, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_delete(self, client: NeMoPlatform) -> None: + response = client.access_keys.with_raw_response.delete( + "jti", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + access_key = response.parse() + assert_matches_type(object, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_delete(self, client: NeMoPlatform) -> None: + with client.access_keys.with_streaming_response.delete( + "jti", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + access_key = response.parse() + assert_matches_type(object, access_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_delete(self, client: NeMoPlatform) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `jti` but received ''"): + client.access_keys.with_raw_response.delete( + "", + ) + + +class TestAsyncAccessKeys: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create(self, async_client: AsyncNeMoPlatform) -> None: + access_key = await async_client.access_keys.create() + assert_matches_type(AccessKeyCreateResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncNeMoPlatform) -> None: + access_key = await async_client.access_keys.create( + expires_in_seconds=1, + name="x", + ) + assert_matches_type(AccessKeyCreateResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_create(self, async_client: AsyncNeMoPlatform) -> None: + response = await async_client.access_keys.with_raw_response.create() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + access_key = await response.parse() + assert_matches_type(AccessKeyCreateResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_create(self, async_client: AsyncNeMoPlatform) -> None: + async with async_client.access_keys.with_streaming_response.create() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + access_key = await response.parse() + assert_matches_type(AccessKeyCreateResponse, access_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncNeMoPlatform) -> None: + access_key = await async_client.access_keys.list() + assert_matches_type(AccessKeyListResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncNeMoPlatform) -> None: + response = await async_client.access_keys.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + access_key = await response.parse() + assert_matches_type(AccessKeyListResponse, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncNeMoPlatform) -> None: + async with async_client.access_keys.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + access_key = await response.parse() + assert_matches_type(AccessKeyListResponse, access_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_delete(self, async_client: AsyncNeMoPlatform) -> None: + access_key = await async_client.access_keys.delete( + "jti", + ) + assert_matches_type(object, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_delete(self, async_client: AsyncNeMoPlatform) -> None: + response = await async_client.access_keys.with_raw_response.delete( + "jti", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + access_key = await response.parse() + assert_matches_type(object, access_key, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncNeMoPlatform) -> None: + async with async_client.access_keys.with_streaming_response.delete( + "jti", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + access_key = await response.parse() + assert_matches_type(object, access_key, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_delete(self, async_client: AsyncNeMoPlatform) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `jti` but received ''"): + await async_client.access_keys.with_raw_response.delete( + "", + ) diff --git a/sdk/python/nemo-platform/tests/api_resources/test_auth.py b/sdk/python/nemo-platform/tests/api_resources/test_auth.py new file mode 100644 index 0000000000..b7e06cc380 --- /dev/null +++ b/sdk/python/nemo-platform/tests/api_resources/test_auth.py @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from tests.utils import assert_matches_type +from nemo_platform import NeMoPlatform, AsyncNeMoPlatform +from nemo_platform.types.auth import AuthenticateResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAuth: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_authenticate(self, client: NeMoPlatform) -> None: + auth = client.auth.authenticate() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_authenticate(self, client: NeMoPlatform) -> None: + response = client.auth.with_raw_response.authenticate() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + auth = response.parse() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_authenticate(self, client: NeMoPlatform) -> None: + with client.auth.with_streaming_response.authenticate() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + auth = response.parse() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_authenticate_get(self, client: NeMoPlatform) -> None: + auth = client.auth.authenticate_get() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_authenticate_get(self, client: NeMoPlatform) -> None: + response = client.auth.with_raw_response.authenticate_get() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + auth = response.parse() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_authenticate_get(self, client: NeMoPlatform) -> None: + with client.auth.with_streaming_response.authenticate_get() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + auth = response.parse() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncAuth: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_authenticate(self, async_client: AsyncNeMoPlatform) -> None: + auth = await async_client.auth.authenticate() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_authenticate(self, async_client: AsyncNeMoPlatform) -> None: + response = await async_client.auth.with_raw_response.authenticate() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + auth = await response.parse() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_authenticate(self, async_client: AsyncNeMoPlatform) -> None: + async with async_client.auth.with_streaming_response.authenticate() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + auth = await response.parse() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_authenticate_get(self, async_client: AsyncNeMoPlatform) -> None: + auth = await async_client.auth.authenticate_get() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_authenticate_get(self, async_client: AsyncNeMoPlatform) -> None: + response = await async_client.auth.with_raw_response.authenticate_get() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + auth = await response.parse() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_authenticate_get(self, async_client: AsyncNeMoPlatform) -> None: + async with async_client.auth.with_streaming_response.authenticate_get() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + auth = await response.parse() + assert_matches_type(AuthenticateResponse, auth, path=["response"]) + + assert cast(Any, response.is_closed) is True diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py index 5ed300825e..300f519139 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_agent.py @@ -176,6 +176,7 @@ def test_commands_uses_visible_command_order_with_discovered_plugins(self): command_rows = [line for line in result.stdout.splitlines() if line.startswith("| nemo ")] assert command_rows == [ "| nemo setup | Setup | Set up NeMo Platform: connect or start services, configure a provider, install skills. |", + "| nemo auth | Setup | Manage authentication for NeMo Platform. |", "| nemo services | Setup | Run platform services locally. |", "| nemo skills | Setup | Install AI agent skill files for Nemo. |", "| nemo chat | CLI functions | Start an interactive chat session with a model. |", diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_auth.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_auth.py index eec6cc4ef1..fd90019b4f 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_auth.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_auth.py @@ -1,16 +1,20 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import json import logging from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest import yaml from nemo_platform.auth.helpers import decode_jwt_claims, generate_unsigned_jwt from nemo_platform.cli.app import app +from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyFeatureDisabledError +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest, AccessKeyCreateResponse from nemo_platform_plugin.client.constants import WORKLOAD_IDENTITY_TOKEN_FILE_ENVVAR from typer.testing import CliRunner @@ -62,6 +66,18 @@ def _decode_jwt_noop(token: str) -> dict: return {} +def _created_access_key(name: str | None = None) -> AccessKeyCreateResponse: + return AccessKeyCreateResponse( + jti="ak_example", + name=name, + token="signed.jwt.token", + token_type="Bearer", + principal="alice@example.com", + created_at=datetime(2026, 7, 28, 12, 0, tzinfo=UTC), + expires_at=None, + ) + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -119,6 +135,52 @@ def oauth_config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return config_path +# --------------------------------------------------------------------------- +# token +# --------------------------------------------------------------------------- + + +def test_auth_token_prints_raw_token(oauth_config_file: Path) -> None: + result = runner.invoke(app, ["--context", "foo", "auth", "token"]) + + assert_exit_code(result, 0) + assert result.output == "foo-token\n" + + +def test_auth_token_decode_prints_claims_json(oauth_config_file: Path) -> None: + token = generate_unsigned_jwt( + principal_id="alice@example.com", + email="alice@example.com", + groups=["team-ml"], + scopes=["openid", "email"], + extra_claims={"iss": "https://idp.example.com"}, + ) + with open(oauth_config_file) as f: + config_data = yaml.safe_load(f) + for user in config_data["users"]: + if user["name"] == "foo": + user["token"] = token + with open(oauth_config_file, "w") as f: + yaml.safe_dump(config_data, f) + + result = runner.invoke(app, ["--context", "foo", "auth", "token", "--decode"]) + + assert_exit_code(result, 0) + claims = json.loads(result.output) + assert claims["sub"] == "alice@example.com" + assert claims["email"] == "alice@example.com" + assert claims["groups"] == ["team-ml"] + assert claims["scope"] == "openid email" + assert claims["iss"] == "https://idp.example.com" + + +def test_auth_token_decode_rejects_malformed_token(oauth_config_file: Path) -> None: + result = runner.invoke(app, ["--context", "foo", "auth", "token", "--decode"]) + + assert_exit_code(result, 1) + assert "Current access token is not a decodable JWT" in result.output + + # --------------------------------------------------------------------------- # logout # --------------------------------------------------------------------------- @@ -284,6 +346,136 @@ def test_auth_refresh_regenerates_unsigned_token(oauth_config_file: Path) -> Non assert refreshed_user.get("refresh_token") is None +def test_auth_access_keys_create_prints_token(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("NMP_BASE_URL", "https://cluster.example.com") + + fake_platform_client = MagicMock() + fake_access_keys_client = MagicMock() + fake_access_keys_client.create_access_key.return_value.data.return_value = _created_access_key() + + monkeypatch.setattr("nemo_platform.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) + monkeypatch.setattr( + "nemo_platform.cli.commands.auth.client_from_platform", + lambda platform, client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "create"]) + + assert_exit_code(result, 0) + assert result.output.strip() == "signed.jwt.token" + body = fake_access_keys_client.create_access_key.call_args.kwargs["body"] + assert body == AccessKeyCreateRequest() + assert "expires_in_seconds" not in body.model_fields_set + + +def test_auth_access_keys_create_sends_optional_name_and_expiration(monkeypatch: pytest.MonkeyPatch): + fake_platform_client = MagicMock() + fake_access_keys_client = MagicMock() + fake_access_keys_client.create_access_key.return_value.data.return_value = _created_access_key("short-lived") + monkeypatch.setattr("nemo_platform.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) + monkeypatch.setattr( + "nemo_platform.cli.commands.auth.client_from_platform", + lambda platform, client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "create", "--name", "short-lived", "--expires-in", "3600"]) + + assert_exit_code(result, 0) + fake_access_keys_client.create_access_key.assert_called_once_with( + body=AccessKeyCreateRequest(name="short-lived", expires_in_seconds=3600), + ) + + +def test_auth_access_keys_create_sends_explicit_null_expiration(monkeypatch: pytest.MonkeyPatch): + fake_platform_client = MagicMock() + fake_access_keys_client = MagicMock() + fake_access_keys_client.create_access_key.return_value.data.return_value = _created_access_key("long-lived") + monkeypatch.setattr("nemo_platform.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) + monkeypatch.setattr( + "nemo_platform.cli.commands.auth.client_from_platform", + lambda platform, client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "create", "--name", "long-lived", "--expires-in", "none"]) + + assert_exit_code(result, 0) + body = fake_access_keys_client.create_access_key.call_args.kwargs["body"] + assert body == AccessKeyCreateRequest(name="long-lived", expires_in_seconds=None) + assert "expires_in_seconds" in body.model_fields_set + + +def test_auth_access_keys_create_rejects_invalid_expiration(monkeypatch: pytest.MonkeyPatch): + fake_platform_client = MagicMock() + fake_access_keys_client = MagicMock() + monkeypatch.setattr("nemo_platform.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) + monkeypatch.setattr( + "nemo_platform.cli.commands.auth.client_from_platform", + lambda platform, client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "create", "--expires-in", "zero"]) + + assert_exit_code(result, 1) + assert "--expires-in must be a positive integer number of seconds" in result.output + assert "'none'." in result.output + fake_access_keys_client.create_access_key.assert_not_called() + + +def test_auth_access_keys_create_reports_disabled_feature(monkeypatch: pytest.MonkeyPatch): + fake_platform_client = MagicMock() + fake_access_keys_client = MagicMock() + fake_access_keys_client.create_access_key.side_effect = AccessKeyFeatureDisabledError( + "Scoped Access Keys are not enabled" + ) + monkeypatch.setattr("nemo_platform.cli.core.context.CLIContext.get_client", lambda self: fake_platform_client) + monkeypatch.setattr( + "nemo_platform.cli.commands.auth.client_from_platform", + lambda platform, client_cls: fake_access_keys_client, + ) + + result = runner.invoke(app, ["auth", "access-keys", "create"]) + + assert result.exit_code == 1 + assert "Scoped Access Keys are not enabled" in result.output + + +def test_auth_access_keys_help_hides_unimplemented_lifecycle_commands() -> None: + result = runner.invoke(app, ["auth", "access-keys", "--help"]) + + assert_exit_code(result, 0) + assert "create" in result.output + assert "list" not in result.output + assert "revoke" not in result.output + + create_help = runner.invoke(app, ["auth", "access-keys", "create", "--help"]) + assert_exit_code(create_help, 0) + assert "Use 'none' to request no expiration" in " ".join(create_help.output.split()) + + list_result = runner.invoke(app, ["auth", "access-keys", "list"]) + revoke_result = runner.invoke(app, ["auth", "access-keys", "revoke", "ak_example"]) + + assert list_result.exit_code != 0 + assert revoke_result.exit_code != 0 + assert "No such command" in list_result.output + assert "No such command" in revoke_result.output + + +def test_auth_tokens_group_is_not_exposed() -> None: + result = runner.invoke(app, ["auth", "tokens", "create"]) + + assert result.exit_code != 0 + assert "No such command" in result.output + assert "tokens" in result.output + + +def test_top_level_access_keys_group_is_not_exposed() -> None: + result = runner.invoke(app, ["access-keys", "--help"]) + + assert result.exit_code != 0 + assert "No such command" in result.output + assert "access-keys" in result.output + + # --------------------------------------------------------------------------- # status # --------------------------------------------------------------------------- diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/test_app.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/test_app.py index 4497b36101..f54afff061 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/test_app.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/test_app.py @@ -265,11 +265,12 @@ def test_root_help_excludes_hidden_commands_and_context_option(): assert result.exit_code == 0 assert "--context" not in result.stdout - for hidden_command in ("auth", "config", "quickstart", "cluster-info", "adapters", "projects"): + assert "\n auth" in result.stdout + for hidden_command in ("config", "quickstart", "cluster-info", "adapters", "projects"): assert f"\n {hidden_command}" not in result.stdout -def test_hidden_command_and_context_option_remain_invokable(): +def test_auth_command_and_hidden_context_option_remain_invokable(): runner = CliRunner() qs_config = QuickstartConfig(auth_enabled=False) diff --git a/sdk/stainless.yaml b/sdk/stainless.yaml index 02a0898d9d..b33b006b48 100644 --- a/sdk/stainless.yaml +++ b/sdk/stainless.yaml @@ -953,3 +953,24 @@ resources: retrieve: get /apis/intake/v2/workspaces/{workspace}/experiments/{name} update: put /apis/intake/v2/workspaces/{workspace}/experiments/{name} delete: delete /apis/intake/v2/workspaces/{workspace}/experiments/{name} + auth: + standalone_api: true + models: + authenticate_error_response: AuthenticateErrorResponse + authenticate_response: AuthenticateResponse + methods: + authenticate_get: get /apis/auth/authenticate + authenticate: post /apis/auth/authenticate + access_keys: + standalone_api: true + models: + access_key_create_request: AccessKeyCreateRequest + access_key_create_response: AccessKeyCreateResponse + access_key_error_response: AccessKeyErrorResponse + access_key_list_response: AccessKeyListResponse + access_key_metadata_response: AccessKeyMetadataResponse + access_key_not_implemented_error_response: AccessKeyNotImplementedErrorResponse + methods: + list: get /apis/auth/v2/access-keys + create: post /apis/auth/v2/access-keys + delete: delete /apis/auth/v2/access-keys/{jti} diff --git a/services/core/auth/src/nmp/core/auth/api/v2/access_keys/endpoints.py b/services/core/auth/src/nmp/core/auth/api/v2/access_keys/endpoints.py new file mode 100644 index 0000000000..a4e6a8cfe6 --- /dev/null +++ b/services/core/auth/src/nmp/core/auth/api/v2/access_keys/endpoints.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from nemo_platform_plugin.auth.access_keys.issuer import ( + AccessKeyFeatureDisabledError, + AccessKeyIssuer, + AccessKeyOperationNotImplementedError, +) +from nmp.common.auth import AuthClient, get_auth_client +from nmp.common.auth.access_keys import AccessKeyIssuerService +from nmp.common.config import get_auth_config + +from . import schemas + +router = APIRouter(tags=["Scoped Access Keys"]) + +_ACCESS_KEY_DISABLED_ERROR_RESPONSE: dict[str, Any] = { + "description": "Scoped Access Keys are not enabled", + "model": schemas.AccessKeyErrorResponse, +} +_ACCESS_KEY_NOT_IMPLEMENTED_ERROR_RESPONSE: dict[str, Any] = { + "description": "Not Implemented", + "model": schemas.AccessKeyNotImplementedErrorResponse, +} +_ACCESS_KEY_CREATE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { + 400: { + "description": "Scoped Access Key creation error", + "model": schemas.AccessKeyErrorResponse, + }, + 404: _ACCESS_KEY_DISABLED_ERROR_RESPONSE, + 501: _ACCESS_KEY_NOT_IMPLEMENTED_ERROR_RESPONSE, +} +_ACCESS_KEY_LIFECYCLE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { + 404: _ACCESS_KEY_DISABLED_ERROR_RESPONSE, + 501: _ACCESS_KEY_NOT_IMPLEMENTED_ERROR_RESPONSE, +} + + +def get_access_key_issuer(auth_client: AuthClient = Depends(get_auth_client)) -> AccessKeyIssuerService: + return AccessKeyIssuerService(config=get_auth_config(), principal=auth_client.principal) + + +def _not_implemented(exc: AccessKeyOperationNotImplementedError) -> HTTPException: + return HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc)) + + +def _disabled(exc: AccessKeyFeatureDisabledError) -> HTTPException: + return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) + + +@router.post( + "/v2/access-keys", + response_model=schemas.AccessKeyCreateResponse, + responses=_ACCESS_KEY_CREATE_ERROR_RESPONSES, +) +async def create_access_key( + request: schemas.AccessKeyCreateRequest, + issuer: AccessKeyIssuerService = Depends(get_access_key_issuer), +) -> schemas.AccessKeyCreateResponse: + try: + return await issuer.create_async(request) + except AccessKeyFeatureDisabledError as exc: + raise _disabled(exc) from exc + except AccessKeyOperationNotImplementedError as exc: + raise _not_implemented(exc) from exc + except RuntimeError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@router.get( + "/v2/access-keys", + response_model=schemas.AccessKeyListResponse, + responses=_ACCESS_KEY_LIFECYCLE_ERROR_RESPONSES, +) +async def list_access_keys(issuer: AccessKeyIssuer = Depends(get_access_key_issuer)) -> schemas.AccessKeyListResponse: + try: + return issuer.list() + except AccessKeyFeatureDisabledError as exc: + raise _disabled(exc) from exc + except AccessKeyOperationNotImplementedError as exc: + raise _not_implemented(exc) from exc + + +@router.delete("/v2/access-keys/{jti}", responses=_ACCESS_KEY_LIFECYCLE_ERROR_RESPONSES) +async def revoke_access_key(jti: str, issuer: AccessKeyIssuer = Depends(get_access_key_issuer)) -> None: + try: + issuer.revoke(jti) + except AccessKeyFeatureDisabledError as exc: + raise _disabled(exc) from exc + except AccessKeyOperationNotImplementedError as exc: + raise _not_implemented(exc) from exc diff --git a/services/core/auth/src/nmp/core/auth/api/v2/access_keys/schemas.py b/services/core/auth/src/nmp/core/auth/api/v2/access_keys/schemas.py new file mode 100644 index 0000000000..b8476099d0 --- /dev/null +++ b/services/core/auth/src/nmp/core/auth/api/v2/access_keys/schemas.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from nemo_platform_plugin.auth.access_keys.types import AccessKeyAuthenticateResponse as AccessKeyAuthenticateResponse +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateRequest as AccessKeyCreateRequest +from nemo_platform_plugin.auth.access_keys.types import AccessKeyCreateResponse as AccessKeyCreateResponse +from nemo_platform_plugin.auth.access_keys.types import AccessKeyListResponse as AccessKeyListResponse +from nemo_platform_plugin.auth.access_keys.types import ( + AccessKeyNotImplementedErrorResponse as AccessKeyNotImplementedErrorResponse, +) +from pydantic import BaseModel + + +class AccessKeyErrorResponse(BaseModel): + """Scoped Access Key error response.""" + + detail: str diff --git a/services/core/auth/src/nmp/core/auth/api/v2/authenticate.py b/services/core/auth/src/nmp/core/auth/api/v2/authenticate.py new file mode 100644 index 0000000000..97fad73e93 --- /dev/null +++ b/services/core/auth/src/nmp/core/auth/api/v2/authenticate.py @@ -0,0 +1,267 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging +from typing import Any + +import jwt +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from nmp.common.auth.bearer import MalformedBearerTokenError, parse_bearer_authorization_header +from nmp.common.auth.jwt import TokenClaims +from nmp.common.auth.token_resolver import ResolvedBearerToken, ResolvedTokenKind, resolve_bearer_token +from nmp.common.config import AuthConfig, get_auth_config +from nmp.core.auth.api.v2.workload_token_exchange import ( + WorkloadTokenExchangeService, + _allowed_audiences, + _workload_token_issuer, + get_workload_token_exchange_service, +) +from pydantic import BaseModel, Field + +router = APIRouter(tags=["Authentication"]) +logger = logging.getLogger(__name__) + + +class AuthenticateErrorResponse(BaseModel): + """Bearer token authentication error response.""" + + detail: str + + +class AuthenticateResponse(BaseModel): + """Successful bearer token authentication response for auth callouts.""" + + principal: str + email: str | None = Field(default=None, json_schema_extra={"nullable": True}) + groups: list[str] = Field(default_factory=list) + scopes: list[str] = Field(default_factory=list) + jti: str | None = Field(default=None, json_schema_extra={"nullable": True}) + token_kind: ResolvedTokenKind + + +_AUTHENTICATE_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = { + 401: { + "description": "Missing or invalid bearer token", + "model": AuthenticateErrorResponse, + }, + 500: { + "description": "Bearer token authentication is misconfigured", + "model": AuthenticateErrorResponse, + }, +} + + +def _bearer_token_from_request(request: Request) -> str: + try: + token = parse_bearer_authorization_header(request.headers.get("authorization")) + except MalformedBearerTokenError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid bearer token") from exc + if token is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") + return token + + +def _groups_from_claim(groups_claim: object) -> list[str]: + if isinstance(groups_claim, str): + return [group.strip() for group in groups_claim.split(",") if group.strip()] + if isinstance(groups_claim, list): + return [group for group in groups_claim if isinstance(group, str)] + return [] + + +def _scopes_from_claims(claims: dict[str, object]) -> list[str]: + scope_claim = claims.get("scope") or claims.get("scp") + if isinstance(scope_claim, str): + return scope_claim.split() + if isinstance(scope_claim, list): + return [scope for scope in scope_claim if isinstance(scope, str)] + return [] + + +def _stamp_principal_headers(response: Response, resolved: ResolvedBearerToken) -> None: + for header_name, header_value in resolved.principal_headers().items(): + response.headers[header_name] = header_value + + +def _response_from_claims( + claims: TokenClaims, + token_kind: ResolvedTokenKind, +) -> AuthenticateResponse: + jti = claims.raw_claims.get("jti") + return AuthenticateResponse( + principal=claims.subject, + email=claims.email, + groups=claims.groups, + scopes=claims.scopes, + jti=jti if isinstance(jti, str) and jti else None, + token_kind=token_kind, + ) + + +async def _validate_workload_access_token( + config: AuthConfig, + request: Request, + token: str, + workload_token_exchange_service: WorkloadTokenExchangeService, +) -> TokenClaims | None: + if not config.oidc.workload_token_exchange_enabled: + return None + + try: + signing_key = await workload_token_exchange_service.workload_signing_key_async(config) + public_key = signing_key.private_key.public_key() + except Exception as exc: + logger.exception("Failed to load workload access token signing key") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Workload token authentication is misconfigured", + ) from exc + + try: + claims = jwt.decode( + token, + public_key, + algorithms=["RS256"], + audience=list(_allowed_audiences(config)), + issuer=_workload_token_issuer(config, request), + options={"require": ["sub", "iat", "nbf", "exp"]}, + leeway=30, + ) + subject = claims.get("sub") + if not isinstance(subject, str) or not subject: + return None + return TokenClaims( + subject=subject, + email=claims.get("email") if isinstance(claims.get("email"), str) else None, + groups=_groups_from_claim(claims.get("groups", [])), + scopes=_scopes_from_claims(claims), + raw_claims=claims, + ) + except jwt.PyJWTError: + return None + + +async def _resolve_workload_access_token( + config: AuthConfig, + request: Request, + workload_token_exchange_service: WorkloadTokenExchangeService, + token: str, +) -> ResolvedBearerToken | None: + claims = await _validate_workload_access_token(config, request, token, workload_token_exchange_service) + if claims is None: + return None + return ResolvedBearerToken(claims=claims, token_kind="workload_access_token") + + +async def _resolve_workload_subject_token( + config: AuthConfig, + workload_token_exchange_service: WorkloadTokenExchangeService, + token: str, +) -> ResolvedBearerToken | None: + if not config.oidc.workload_token_exchange_enabled or not config.oidc.workload_subject_jwks_uri: + return None + + try: + claims = await workload_token_exchange_service.decode_jwt_subject_token(config, token) + except jwt.PyJWTError: + return None + + subject = claims.get(config.oidc.subject_claim, claims.get("sub")) + if not isinstance(subject, str) or not subject: + return None + + email = claims.get(config.oidc.email_claim) + token_claims = TokenClaims( + subject=subject, + email=email if isinstance(email, str) else None, + groups=_groups_from_claim(claims.get(config.oidc.groups_claim, claims.get("groups", []))), + scopes=_scopes_from_claims(claims), + raw_claims=claims, + ) + return ResolvedBearerToken(claims=token_claims, token_kind="workload_subject_token") + + +async def _authenticate_bearer_token( + request: Request, + response: Response, + workload_token_exchange_service: WorkloadTokenExchangeService, +) -> AuthenticateResponse: + token = _bearer_token_from_request(request) + config = get_auth_config() + + async def resolve_workload_access(candidate: str) -> ResolvedBearerToken | None: + return await _resolve_workload_access_token(config, request, workload_token_exchange_service, candidate) + + async def resolve_workload_subject(candidate: str) -> ResolvedBearerToken | None: + return await _resolve_workload_subject_token(config, workload_token_exchange_service, candidate) + + resolved = await resolve_bearer_token( + config, + token, + extra_resolvers=[resolve_workload_access, resolve_workload_subject], + ) + if resolved is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid bearer token") + + _stamp_principal_headers(response, resolved) + return _response_from_claims(resolved.claims, resolved.token_kind) + + +@router.get( + "/authenticate", + response_model=AuthenticateResponse, + operation_id="get_authenticate_bearer_token", + responses=_AUTHENTICATE_ERROR_RESPONSES, +) +async def authenticate_bearer_token_get( + request: Request, + response: Response, + workload_token_exchange_service: WorkloadTokenExchangeService = Depends(get_workload_token_exchange_service), +) -> AuthenticateResponse: + return await _authenticate_bearer_token(request, response, workload_token_exchange_service) + + +@router.post( + "/authenticate", + response_model=AuthenticateResponse, + operation_id="post_authenticate_bearer_token", + responses=_AUTHENTICATE_ERROR_RESPONSES, +) +async def authenticate_bearer_token_post( + request: Request, + response: Response, + workload_token_exchange_service: WorkloadTokenExchangeService = Depends(get_workload_token_exchange_service), +) -> AuthenticateResponse: + return await _authenticate_bearer_token(request, response, workload_token_exchange_service) + + +@router.api_route( + "/authenticate", + methods=["DELETE", "PATCH", "PUT", "OPTIONS"], + response_model=AuthenticateResponse, + include_in_schema=False, +) +async def authenticate_bearer_token_callout_methods( + request: Request, + response: Response, + workload_token_exchange_service: WorkloadTokenExchangeService = Depends(get_workload_token_exchange_service), +) -> AuthenticateResponse: + return await _authenticate_bearer_token(request, response, workload_token_exchange_service) + + +@router.api_route( + "/authenticate/{original_path:path}", + methods=["DELETE", "GET", "PATCH", "POST", "PUT", "OPTIONS"], + response_model=AuthenticateResponse, + include_in_schema=False, +) +async def authenticate_bearer_token_prefixed_callout( + request: Request, + response: Response, + original_path: str, + workload_token_exchange_service: WorkloadTokenExchangeService = Depends(get_workload_token_exchange_service), +) -> AuthenticateResponse: + _ = original_path + return await _authenticate_bearer_token(request, response, workload_token_exchange_service) diff --git a/services/core/auth/src/nmp/core/auth/api/v2/workload_token_exchange.py b/services/core/auth/src/nmp/core/auth/api/v2/workload_token_exchange.py index dd98d4e52a..fa42f8246a 100644 --- a/services/core/auth/src/nmp/core/auth/api/v2/workload_token_exchange.py +++ b/services/core/auth/src/nmp/core/auth/api/v2/workload_token_exchange.py @@ -10,17 +10,15 @@ import time from collections.abc import Awaitable, Callable from dataclasses import dataclass -from hashlib import sha256 from pathlib import Path from typing import Any import httpx import jwt -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse -from jwt.algorithms import RSAAlgorithm +from nmp.common.auth.access_keys import public_jwk_from_private_key_pem_async +from nmp.common.auth.signing_keys import RSASigningKey, RSASigningKeyCache from nmp.common.config import AuthConfig, get_auth_config, get_platform_config from pydantic import BaseModel, ConfigDict, Field @@ -84,13 +82,6 @@ router = APIRouter(tags=["Workload Identity"]) -@dataclass(frozen=True) -class _WorkloadSigningKey: - private_key: rsa.RSAPrivateKey - public_key: rsa.RSAPublicKey - kid: str - - @dataclass(frozen=True) class _SubjectJWKSCacheEntry: jwks: dict[str, Any] @@ -103,6 +94,14 @@ class _SubjectTokenDecoder: decode: Callable[[], Awaitable[dict[str, Any]]] +@dataclass(frozen=True) +class _SigningKeyLoadRequest: + kid: str + private_key_file: str | None + missing_private_key_message: str + invalid_private_key_message: str + + class WorkloadTokenExchangeResponse(BaseModel): """RFC 8693 token exchange response for workload identity access tokens.""" @@ -159,6 +158,9 @@ class HTTPValidationError(BaseModel): _WORKLOAD_TOKEN_EXCHANGE_SERVICE_STATE_KEY = "workload_token_exchange_service" +_MISSING_WORKLOAD_TOKEN_KEY_ID_MESSAGE = ( + "auth.oidc.workload_token_key_id or auth.token_signing.key_id must be configured for workload token exchange" +) def _platform_base_url_from_request(request: Request | None) -> str: @@ -178,18 +180,32 @@ def workload_jwks_url(request: Request | None = None) -> str: def _workload_token_issuer(config: AuthConfig, request: Request | None) -> str: - return config.oidc.workload_token_issuer or f"{_platform_base_url_from_request(request)}/apis/auth" + return ( + config.oidc.workload_token_issuer + or config.token_signing.issuer + or f"{_platform_base_url_from_request(request)}/apis/auth" + ) -def _workload_private_key_pem(config: AuthConfig) -> bytes: - private_key_file = config.oidc.workload_token_private_key_file - if private_key_file: - try: - return Path(private_key_file).read_bytes() - except OSError as exc: - raise RuntimeError(f"Could not read workload token private key file: {private_key_file}") from exc +def _workload_token_key_id(config: AuthConfig) -> str: + return config.oidc.workload_token_key_id or config.token_signing.key_id + - raise RuntimeError("workload_token_private_key_file must be configured for workload token exchange") +def _workload_private_key_file(config: AuthConfig) -> str | None: + return config.oidc.workload_token_private_key_file or config.token_signing.private_key_file + + +def _workload_signing_key_load_request(config: AuthConfig) -> _SigningKeyLoadRequest: + kid = _workload_token_key_id(config) + if not kid: + raise RuntimeError(_MISSING_WORKLOAD_TOKEN_KEY_ID_MESSAGE) + + return _SigningKeyLoadRequest( + kid=kid, + private_key_file=_workload_private_key_file(config), + missing_private_key_message="auth.token_signing.private_key_file must be configured for workload token exchange", + invalid_private_key_message="workload token private key must be an RSA private key", + ) def _subject_token_key_id(subject_token: str) -> str: @@ -218,34 +234,45 @@ def _validate_subject_jwks(jwks: dict[str, Any]) -> None: class WorkloadTokenExchangeService: """Stateful helpers for workload token exchange endpoints.""" - def __init__(self) -> None: - self._workload_signing_key_cache: dict[tuple[str, str], _WorkloadSigningKey] = {} + def __init__(self, signing_key_cache: RSASigningKeyCache | None = None) -> None: + self._signing_key_cache = signing_key_cache or RSASigningKeyCache() self._subject_jwks_cache: dict[str, _SubjectJWKSCacheEntry] = {} - def workload_signing_key(self, config: AuthConfig) -> _WorkloadSigningKey: - kid = config.oidc.workload_token_key_id - if not kid: - raise RuntimeError("workload_token_key_id must be configured for workload token exchange") - - private_key_pem = _workload_private_key_pem(config) - cache_key = (kid, sha256(private_key_pem).hexdigest()) - cached = self._workload_signing_key_cache.get(cache_key) - if cached is not None: - return cached - - private_key = serialization.load_pem_private_key(private_key_pem, password=None) - if not isinstance(private_key, rsa.RSAPrivateKey): - raise RuntimeError("workload token private key must be an RSA private key") + def workload_signing_key(self, config: AuthConfig) -> RSASigningKey: + load_request = _workload_signing_key_load_request(config) + return self._signing_key_cache.get_from_file( + kid=load_request.kid, + private_key_file=load_request.private_key_file, + missing_private_key_message=load_request.missing_private_key_message, + invalid_private_key_message=load_request.invalid_private_key_message, + ) - signing_key = _WorkloadSigningKey(private_key=private_key, public_key=private_key.public_key(), kid=kid) - self._workload_signing_key_cache[cache_key] = signing_key - return signing_key + async def workload_signing_key_async(self, config: AuthConfig) -> RSASigningKey: + load_request = _workload_signing_key_load_request(config) + return await self._signing_key_cache.get_from_file_async( + kid=load_request.kid, + private_key_file=load_request.private_key_file, + missing_private_key_message=load_request.missing_private_key_message, + invalid_private_key_message=load_request.invalid_private_key_message, + ) def public_jwk(self, config: AuthConfig) -> dict[str, Any]: - signing_key = self.workload_signing_key(config) - jwk = json.loads(RSAAlgorithm.to_jwk(signing_key.public_key)) - jwk.update({"kid": signing_key.kid, "use": "sig", "alg": "RS256"}) - return jwk + load_request = _workload_signing_key_load_request(config) + return self._signing_key_cache.public_jwk_from_file( + kid=load_request.kid, + private_key_file=load_request.private_key_file, + missing_private_key_message=load_request.missing_private_key_message, + invalid_private_key_message=load_request.invalid_private_key_message, + ) + + async def public_jwk_async(self, config: AuthConfig) -> dict[str, Any]: + load_request = _workload_signing_key_load_request(config) + return await self._signing_key_cache.public_jwk_from_file_async( + kid=load_request.kid, + private_key_file=load_request.private_key_file, + missing_private_key_message=load_request.missing_private_key_message, + invalid_private_key_message=load_request.invalid_private_key_message, + ) async def fetch_subject_jwks(self, config: AuthConfig, *, refresh: bool = False) -> dict[str, Any]: jwks_uri = config.oidc.workload_subject_jwks_uri @@ -329,6 +356,30 @@ def get_workload_token_exchange_service(request: Request) -> WorkloadTokenExchan return service +def _dedupe_public_jwks(keys: list[dict[str, Any]]) -> list[dict[str, Any]]: + deduped: list[dict[str, Any]] = [] + seen: set[str] = set() + for key in keys: + identity = json.dumps(key, sort_keys=True) + if identity in seen: + continue + seen.add(identity) + deduped.append(key) + return deduped + + +async def auth_jwks_response( + config: AuthConfig, + workload_token_exchange_service: WorkloadTokenExchangeService, +) -> JsonWebKeySetResponse: + keys: list[dict[str, Any]] = [] + if config.oidc.workload_token_exchange_enabled: + keys.append(await workload_token_exchange_service.public_jwk_async(config)) + if config.access_keys.enabled: + keys.append(await public_jwk_from_private_key_pem_async(config)) + return JsonWebKeySetResponse(keys=[JsonWebKey(**key) for key in _dedupe_public_jwks(keys)]) + + def _oauth_error(status_code: int, error: str, description: str) -> JSONResponse: # Keep descriptions passed to clients fixed and non-sensitive. Detailed # validation errors are logged at call sites instead of returned here. @@ -454,7 +505,7 @@ async def jwks( workload_token_exchange_service: WorkloadTokenExchangeService = Depends(get_workload_token_exchange_service), ) -> JsonWebKeySetResponse: """Return workload identity exchange signing keys.""" - return JsonWebKeySetResponse(keys=[JsonWebKey(**workload_token_exchange_service.public_jwk(get_auth_config()))]) + return await auth_jwks_response(get_auth_config(), workload_token_exchange_service) @router.post( @@ -536,7 +587,7 @@ async def token_exchange( if groups_claim: exchanged_claims["groups"] = groups_claim - signing_key = workload_token_exchange_service.workload_signing_key(config) + signing_key = await workload_token_exchange_service.workload_signing_key_async(config) access_token = jwt.encode( exchanged_claims, signing_key.private_key, diff --git a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml index 1e867f5e42..af6898e0a8 100644 --- a/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml +++ b/services/core/auth/src/nmp/core/auth/assets/static-authz.yaml @@ -427,6 +427,13 @@ authz: get: permissions: [] scopes: [] + /apis/auth/authenticate: + get: + permissions: [] + scopes: [] + post: + permissions: [] + scopes: [] /apis/auth/jwks: get: permissions: [] @@ -435,6 +442,17 @@ authz: post: permissions: [] scopes: [] + /apis/auth/v2/access-keys: + get: + permissions: [] + scopes: [] + post: + permissions: [] + scopes: [] + /apis/auth/v2/access-keys/{jti}: + delete: + permissions: [] + scopes: [] /apis/auth/v2/iam/opa-bundle.tar.gz: get: permissions: diff --git a/services/core/auth/src/nmp/core/auth/service.py b/services/core/auth/src/nmp/core/auth/service.py index 2140fe38c5..a8095a8851 100644 --- a/services/core/auth/src/nmp/core/auth/service.py +++ b/services/core/auth/src/nmp/core/auth/service.py @@ -7,6 +7,8 @@ import logging from typing import ClassVar, List, Optional +import nmp.core.auth.api.v2.access_keys.endpoints as access_keys +import nmp.core.auth.api.v2.authenticate as authenticate from nmp.common.config import get_service_config from nmp.common.service import RouterConfig, Service from nmp.core.auth.api.v2 import workload_token_exchange @@ -38,6 +40,10 @@ def get_routers(self) -> List[RouterConfig]: RouterConfig(iam.router, tag="IAM", description="Identity and Access Management endpoints"), RouterConfig(bundle.router, tag="Bundle", description="OPA bundle endpoints"), RouterConfig(discovery.router, tag="Discovery", description="Platform configuration discovery endpoints"), + RouterConfig( + authenticate.router, tag="Authentication", description="Bearer token authentication endpoints" + ), + RouterConfig(access_keys.router, tag="Scoped Access Keys", description="Scoped Access Key endpoints"), RouterConfig( workload_token_exchange.router, tag="Workload Identity", diff --git a/services/core/auth/tests/integration/test_scoped_access_keys.py b/services/core/auth/tests/integration/test_scoped_access_keys.py new file mode 100644 index 0000000000..b063cfdde2 --- /dev/null +++ b/services/core/auth/tests/integration/test_scoped_access_keys.py @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import uuid +from pathlib import Path +from unittest.mock import patch + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi.testclient import TestClient +from nmp.common.auth.access_keys import public_jwk_from_private_key_pem, validate_access_key_token +from nmp.common.auth.jwt import TokenClaims +from nmp.common.config import AuthConfig +from nmp.common.config.base import AccessKeyConfig, TokenSigningConfig +from nmp.core.auth.config import AuthServiceConfig +from nmp.testing.client import create_test_client + +ACCESS_KEYS_PATH = "/apis/auth/v2/access-keys" +IAM_ROLE_BINDINGS_PATH = "/apis/auth/v2/iam/role-bindings" +WORKSPACES_PATH = "/apis/entities/v2/workspaces" +SERVICE_HEADERS = {"X-NMP-Principal-Id": "service:integration-test"} + + +def _tamper_jwt(token: str) -> str: + """Return a JWT with its signature bytes changed so validation must fail.""" + parts = token.split(".") + assert len(parts) == 3 + signature = parts[2] + replacement = "A" if signature[0] != "A" else "B" + parts[2] = f"{replacement}{signature[1:]}" + return ".".join(parts) + + +def _write_private_key(path: Path) -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + path.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + +def _auth_configs(private_key_file: str) -> tuple[AuthConfig, AuthServiceConfig]: + shared_config = AuthConfig( + enabled=True, + policy_decision_point_provider="embedded", + policy_decision_point_base_url="http://testserver", + propagation_poll_interval_seconds=0.05, + token_signing=TokenSigningConfig( + issuer="http://testserver/apis/auth", + key_id="integration-access-key", + private_key_file=private_key_file, + ), + access_keys=AccessKeyConfig(enabled=True), + ) + service_config = AuthServiceConfig( + **shared_config.model_dump(), + policy_data_refresh_interval=0.05, + bundle_cache_seconds=0, + admin_email="admin@example.com", + ) + return shared_config, service_config + + +def test_scoped_access_key_created_by_auth_service_authenticates_platform_requests(tmp_path: Path) -> None: + private_key_file = tmp_path / "access-key-private.pem" + _write_private_key(private_key_file) + shared_config, service_config = _auth_configs(str(private_key_file)) + assert shared_config.oidc.workload_token_exchange_enabled is False + + with create_test_client( + client_type=TestClient, + auth_enabled=True, + service_configs={ + AuthConfig: shared_config, + AuthServiceConfig: service_config, + }, + ) as client: + workspace = f"access-key-smoke-{uuid.uuid4().hex[:8]}" + group = f"access-key-group-{uuid.uuid4().hex[:8]}" + user = f"access-key-user-{uuid.uuid4().hex[:8]}@example.com" + user_headers = { + "X-NMP-Principal-Id": user, + "X-NMP-Principal-Email": user, + "X-NMP-Principal-Groups": group, + } + + create_workspace = client.post( + WORKSPACES_PATH, + json={"name": workspace, "description": "Scoped Access Key integration smoke"}, + headers=SERVICE_HEADERS, + ) + assert create_workspace.status_code in {200, 201}, create_workspace.text + + try: + create_key = client.post( + ACCESS_KEYS_PATH, + json={"name": "integration-smoke"}, + headers=user_headers, + ) + assert create_key.status_code == 200, create_key.text + access_key = create_key.json()["token"] + + role_binding = client.post( + IAM_ROLE_BINDINGS_PATH, + json={"principal": group, "role": "Viewer", "workspace": workspace}, + headers=SERVICE_HEADERS, + ) + assert role_binding.status_code in {200, 201}, role_binding.text + + jwks = {"keys": [public_jwk_from_private_key_pem(shared_config)]} + + async def validate_with_local_jwks(config: AuthConfig, token: str) -> TokenClaims | None: + return await validate_access_key_token(config, token, jwks_override=jwks) + + with patch("nmp.common.auth.access_keys.validate_access_key_token", validate_with_local_jwks): + response = client.get( + f"{WORKSPACES_PATH}/{workspace}", + headers={"Authorization": f"Bearer {access_key}"}, + ) + + assert response.status_code == 200, response.text + assert response.json()["name"] == workspace + + with patch("nmp.common.auth.access_keys.validate_access_key_token", validate_with_local_jwks): + authenticate_response = client.get( + "/apis/auth/authenticate", + headers={"Authorization": f"Bearer {access_key}"}, + ) + invalid_authenticate_response = client.get( + "/apis/auth/authenticate", + headers={"Authorization": f"Bearer {_tamper_jwt(access_key)}"}, + ) + + assert authenticate_response.status_code == 200, authenticate_response.text + assert authenticate_response.json()["principal"] == user + assert authenticate_response.json()["token_kind"] == "access_key" + assert invalid_authenticate_response.status_code == 401, invalid_authenticate_response.text + + with patch("nmp.common.auth.access_keys.validate_access_key_token", validate_with_local_jwks): + invalid_workspace_response = client.get( + f"{WORKSPACES_PATH}/{workspace}", + headers={"Authorization": f"Bearer {_tamper_jwt(access_key)}"}, + ) + + assert invalid_workspace_response.status_code == 401, invalid_workspace_response.text + finally: + client.delete(f"{WORKSPACES_PATH}/{workspace}", headers=SERVICE_HEADERS) diff --git a/services/core/auth/tests/test_access_keys.py b/services/core/auth/tests/test_access_keys.py new file mode 100644 index 0000000000..2dd7096543 --- /dev/null +++ b/services/core/auth/tests/test_access_keys.py @@ -0,0 +1,229 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from nemo_platform_plugin.auth.access_keys.issuer import AccessKeyOperationNotImplementedError +from nmp.common.auth.client import AuthClient +from nmp.common.auth.dependencies import auth_client_context +from nmp.common.auth.models import Principal +from nmp.common.config import AuthConfig +from nmp.common.config.base import AccessKeyConfig, TokenSigningConfig +from nmp.core.auth.api.v2.access_keys.endpoints import get_access_key_issuer, router + + +@pytest.fixture +def client(tmp_path): + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig( + issuer="http://testserver/apis/auth", + key_id="test-access-key", + private_key_file=str(tmp_path / "private.pem"), + ), + access_keys=AccessKeyConfig( + enabled=True, + audience="nemo-platform-access-key", + ), + ) + + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + (tmp_path / "private.pem").write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + app = FastAPI() + app.include_router(router) + + token = auth_client_context.set( + AuthClient( + principal=Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]), + config=config, + ) + ) + with patch("nmp.core.auth.api.v2.access_keys.endpoints.get_auth_config", return_value=config): + yield TestClient(app) + auth_client_context.reset(token) + + +@pytest.fixture +def disabled_client(): + config = AuthConfig(enabled=True, access_keys=AccessKeyConfig()) + app = FastAPI() + app.include_router(router) + token = auth_client_context.set( + AuthClient( + principal=Principal(id="alice@example.com", email="alice@example.com", groups=["team-ml"]), + config=config, + ) + ) + with patch("nmp.core.auth.api.v2.access_keys.endpoints.get_auth_config", return_value=config): + yield TestClient(app) + auth_client_context.reset(token) + + +def test_create_access_key_returns_token_for_current_principal(client): + response = client.post("/v2/access-keys", json={"name": "gtc-intake", "expires_in_seconds": 3600}) + + assert response.status_code == 200 + body = response.json() + assert body["jti"].startswith("ak_") + assert body["name"] == "gtc-intake" + assert body["token_type"] == "Bearer" + assert body["principal"] == "alice@example.com" + assert body["expires_at"] is not None + assert body["token"].count(".") == 2 + + +def test_create_access_key_allows_unnamed_tokens(client): + response = client.post("/v2/access-keys", json={"expires_in_seconds": 3600}) + + assert response.status_code == 200 + body = response.json() + assert body["jti"].startswith("ak_") + assert body["name"] is None + assert body["token"].count(".") == 2 + + +def test_create_access_key_defaults_expiration_when_omitted(client): + response = client.post("/v2/access-keys", json={"name": "gtc-intake"}) + + assert response.status_code == 200 + body = response.json() + assert body["jti"].startswith("ak_") + assert body["name"] == "gtc-intake" + assert body["expires_at"] is not None + assert body["token"].count(".") == 2 + + +def test_create_access_key_rejects_explicit_null_expiration_when_max_configured(client): + response = client.post( + "/v2/access-keys", + json={"name": "bad-request", "expires_in_seconds": None}, + ) + + assert response.status_code == 400 + assert "expires_in_seconds=null requires auth.access_keys.max_expires_in_seconds" in response.json()["detail"] + + +def test_create_access_key_accepts_optional_expiration(client): + response = client.post("/v2/access-keys", json={"name": "short-lived", "expires_in_seconds": 60}) + + assert response.status_code == 200 + assert response.json()["expires_at"] is not None + + +def test_create_access_key_is_disabled_by_default(disabled_client): + response = disabled_client.post("/v2/access-keys", json={}) + + assert response.status_code == 404 + assert response.json()["detail"] == "Scoped Access Keys are not enabled" + + +def test_create_access_key_is_explicitly_not_implemented(client): + class NotImplementedIssuer: + async def create_async(self, request): + raise AccessKeyOperationNotImplementedError("Scoped Access Key creation is not implemented") + + client.app.dependency_overrides[get_access_key_issuer] = lambda: NotImplementedIssuer() + try: + response = client.post("/v2/access-keys", json={"name": "gtc-intake"}) + finally: + client.app.dependency_overrides.clear() + + assert response.status_code == 501 + assert response.json()["detail"] == "Scoped Access Key creation is not implemented" + + +def test_list_access_keys_is_disabled_by_default(disabled_client): + response = disabled_client.get("/v2/access-keys") + + assert response.status_code == 404 + assert response.json()["detail"] == "Scoped Access Keys are not enabled" + + +def test_revoke_access_key_is_disabled_by_default(disabled_client): + response = disabled_client.delete("/v2/access-keys/ak_example") + + assert response.status_code == 404 + assert response.json()["detail"] == "Scoped Access Keys are not enabled" + + +def test_access_key_specific_jwks_route_is_removed(client): + response = client.get("/v2/access-keys/jwks") + + assert response.status_code == 405 + assert "DELETE" in response.headers["allow"] + + +def test_access_key_specific_jwks_route_is_not_in_openapi(client): + assert "/v2/access-keys/jwks" not in client.app.openapi()["paths"] + + +def test_access_key_lifecycle_openapi_documents_error_responses(client): + openapi = client.app.openapi() + + create_responses = openapi["paths"]["/v2/access-keys"]["post"]["responses"] + assert create_responses["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyCreateResponse" + } + assert create_responses["400"]["description"] == "Scoped Access Key creation error" + assert create_responses["400"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyErrorResponse" + } + assert create_responses["404"]["description"] == "Scoped Access Keys are not enabled" + assert create_responses["404"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyErrorResponse" + } + assert create_responses["501"]["description"] == "Not Implemented" + assert create_responses["501"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyNotImplementedErrorResponse" + } + + list_responses = openapi["paths"]["/v2/access-keys"]["get"]["responses"] + assert list_responses["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyListResponse" + } + assert list_responses["404"]["description"] == "Scoped Access Keys are not enabled" + assert list_responses["404"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyErrorResponse" + } + assert list_responses["501"]["description"] == "Not Implemented" + assert list_responses["501"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyNotImplementedErrorResponse" + } + + revoke_responses = openapi["paths"]["/v2/access-keys/{jti}"]["delete"]["responses"] + assert revoke_responses["200"]["content"]["application/json"]["schema"] == {} + assert revoke_responses["404"]["description"] == "Scoped Access Keys are not enabled" + assert revoke_responses["404"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyErrorResponse" + } + assert revoke_responses["501"]["description"] == "Not Implemented" + assert revoke_responses["501"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AccessKeyNotImplementedErrorResponse" + } + + +def test_list_access_keys_is_explicitly_not_implemented(client): + response = client.get("/v2/access-keys") + + assert response.status_code == 501 + assert response.json()["detail"] == "Scoped Access Key listing is not implemented." + + +def test_revoke_access_key_is_explicitly_not_implemented(client): + response = client.delete("/v2/access-keys/ak_example") + + assert response.status_code == 501 + assert response.json()["detail"] == "Scoped Access Key revocation for ak_example is not implemented." diff --git a/services/core/auth/tests/test_authenticate.py b/services/core/auth/tests/test_authenticate.py new file mode 100644 index 0000000000..493307ac74 --- /dev/null +++ b/services/core/auth/tests/test_authenticate.py @@ -0,0 +1,350 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import logging +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime, timedelta +from typing import cast +from unittest.mock import AsyncMock, patch + +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import FastAPI +from fastapi.testclient import TestClient +from nmp.common.auth.jwt import TokenClaims +from nmp.common.auth.token_resolver import ResolvedBearerToken +from nmp.common.config import AuthConfig +from nmp.common.config.base import AccessKeyConfig, OIDCConfig, TokenSigningConfig +from nmp.core.auth.api.v2.authenticate import router +from nmp.core.auth.api.v2.workload_token_exchange import ( + WorkloadTokenExchangeService, + get_workload_token_exchange_service, +) + + +def _private_key_pem() -> bytes: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +@contextmanager +def _test_client( + config: AuthConfig, + *, + workload_token_exchange_service: WorkloadTokenExchangeService | None = None, +) -> Iterator[TestClient]: + app = FastAPI() + app.include_router(router) + if workload_token_exchange_service is not None: + app.dependency_overrides[get_workload_token_exchange_service] = lambda: workload_token_exchange_service + with patch("nmp.core.auth.api.v2.authenticate.get_auth_config", return_value=config): + yield TestClient(app) + + +def test_authenticate_access_key_returns_principal_headers(tmp_path): + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig( + issuer="http://testserver/apis/auth", + key_id="test-access-key", + private_key_file=str(tmp_path / "private.pem"), + ), + access_keys=AccessKeyConfig(enabled=True), + ) + (tmp_path / "private.pem").write_bytes(_private_key_pem()) + claims = TokenClaims( + subject="alice@example.com", + email="alice@example.com", + groups=["team-ml"], + scopes=["models:read"], + raw_claims={"jti": "ak_example", "nmp_token_type": "access_key"}, + ) + resolved = ResolvedBearerToken(claims=claims, token_kind="access_key") + with ( + _test_client(config) as client, + patch( + "nmp.core.auth.api.v2.authenticate.resolve_bearer_token", + new=AsyncMock(return_value=resolved), + ) as resolver, + ): + response = client.post( + "/authenticate", + headers={"Authorization": "Bearer signed.jwt.token"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "principal": "alice@example.com", + "email": "alice@example.com", + "groups": ["team-ml"], + "scopes": ["models:read"], + "jti": "ak_example", + "token_kind": "access_key", + } + assert response.headers["X-NMP-Principal-Id"] == "alice@example.com" + assert response.headers["X-NMP-Principal-Email"] == "alice@example.com" + assert response.headers["X-NMP-Principal-Groups"] == "team-ml" + assert response.headers["X-NMP-Scopes"] == "models:read" + resolver_call = resolver.await_args + assert resolver_call is not None + assert resolver_call.args[:2] == (config, "signed.jwt.token") + assert len(resolver_call.kwargs["extra_resolvers"]) == 2 + + +def test_authenticate_callout_accepts_original_request_methods(tmp_path): + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig(private_key_file=str(tmp_path / "private.pem")), + access_keys=AccessKeyConfig(enabled=True), + ) + (tmp_path / "private.pem").write_bytes(_private_key_pem()) + claims = TokenClaims( + subject="writer@example.com", + email=None, + groups=[], + scopes=["models:write"], + raw_claims={"nmp_token_type": "access_key"}, + ) + resolved = ResolvedBearerToken(claims=claims, token_kind="access_key") + with ( + _test_client(config) as client, + patch( + "nmp.core.auth.api.v2.authenticate.resolve_bearer_token", + new=AsyncMock(return_value=resolved), + ) as resolver, + ): + response = client.delete( + "/authenticate/apis/entities/v2/workspaces/default", + headers={"Authorization": "Bearer signed.jwt.token"}, + ) + + assert response.status_code == 200 + assert response.json()["principal"] == "writer@example.com" + assert response.headers["X-NMP-Principal-Id"] == "writer@example.com" + assert response.headers["X-NMP-Scopes"] == "models:write" + resolver.assert_awaited_once() + + +def test_authenticate_rejects_unresolved_bearer_token(tmp_path): + config = AuthConfig(enabled=True, token_signing=TokenSigningConfig(private_key_file=str(tmp_path / "private.pem"))) + (tmp_path / "private.pem").write_bytes(_private_key_pem()) + + with ( + _test_client(config) as client, + patch("nmp.core.auth.api.v2.authenticate.resolve_bearer_token", new=AsyncMock(return_value=None)), + ): + response = client.get("/authenticate", headers={"Authorization": "Bearer invalid.token"}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid bearer token" + + +def test_authenticate_workload_access_token_returns_principal_headers(tmp_path): + private_key_file = tmp_path / "private.pem" + private_key_file.write_bytes(_private_key_pem()) + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig( + issuer="http://testserver/apis/auth", + key_id="test-workload", + private_key_file=str(private_key_file), + ), + oidc=OIDCConfig( + workload_token_exchange_enabled=True, + workload_audience="nemo-platform", + ), + ) + signing_key = WorkloadTokenExchangeService().workload_signing_key(config) + now = datetime.now(tz=UTC) + token = jwt.encode( + { + "iss": "http://testserver/apis/auth", + "sub": "system:serviceaccount:nemo:job", + "aud": "nemo-platform", + "iat": now, + "nbf": now, + "exp": now + timedelta(minutes=5), + "scope": "openid email groups", + "groups": "team-ml,team-ai", + }, + signing_key.private_key, + algorithm="RS256", + headers={"kid": signing_key.kid}, + ) + with _test_client(config) as client: + response = client.get( + "/authenticate", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "principal": "system:serviceaccount:nemo:job", + "email": None, + "groups": ["team-ml", "team-ai"], + "scopes": ["openid", "email", "groups"], + "jti": None, + "token_kind": "workload_access_token", + } + assert response.headers["X-NMP-Principal-Id"] == "system:serviceaccount:nemo:job" + assert response.headers["X-NMP-Principal-Groups"] == "team-ml,team-ai" + assert response.headers["X-NMP-Scopes"] == "openid email groups" + + +def test_authenticate_workload_subject_token_uses_resolver_callback(tmp_path): + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig(private_key_file=str(tmp_path / "private.pem")), + oidc=OIDCConfig( + enabled=True, + issuer="https://sso.example.com/application/o/nemo-cli/", + client_id="nemo-platform-cli", + workload_token_exchange_enabled=True, + workload_client_id="nemo-platform-workload", + workload_subject_jwks_uri="https://sso.example.com/application/o/nemo-workload/jwks/", + workload_subject_issuers=["https://sso.example.com/application/o/nemo-workload/"], + ), + ) + (tmp_path / "private.pem").write_bytes(_private_key_pem()) + subject_claims = { + "sub": "svc-nemo", + "email": "svc-nemo@example.com", + "groups": "nemo-workloads", + "scope": "openid email groups", + } + exchange_service = WorkloadTokenExchangeService() + + async def resolve_via_subject_callback(config_arg, token_arg, **kwargs): + assert config_arg == config + assert token_arg == "workload.subject.token" + extra_resolvers = kwargs["extra_resolvers"] + assert len(extra_resolvers) == 2 + return await extra_resolvers[1](token_arg) + + with ( + _test_client(config, workload_token_exchange_service=exchange_service) as client, + patch( + "nmp.core.auth.api.v2.authenticate.resolve_bearer_token", + new=AsyncMock(side_effect=resolve_via_subject_callback), + ), + patch.object(exchange_service, "decode_jwt_subject_token", new=AsyncMock(return_value=subject_claims)), + ): + response = client.get("/authenticate", headers={"Authorization": "Bearer workload.subject.token"}) + + assert response.status_code == 200 + assert response.json()["principal"] == "svc-nemo" + assert response.json()["token_kind"] == "workload_subject_token" + assert response.headers["X-NMP-Principal-Id"] == "svc-nemo" + assert response.headers["X-NMP-Principal-Email"] == "svc-nemo@example.com" + assert response.headers["X-NMP-Principal-Groups"] == "nemo-workloads" + assert response.headers["X-NMP-Scopes"] == "openid email groups" + + +def test_authenticate_invalid_workload_access_token_returns_401(tmp_path): + private_key_file = tmp_path / "private.pem" + private_key_file.write_bytes(_private_key_pem()) + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig( + issuer="http://testserver/apis/auth", + key_id="test-workload", + private_key_file=str(private_key_file), + ), + oidc=OIDCConfig( + workload_token_exchange_enabled=True, + workload_audience="nemo-platform", + ), + ) + with _test_client(config) as client: + response = client.get( + "/authenticate", + headers={"Authorization": "Bearer not-a-jwt"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid bearer token" + + +def test_authenticate_workload_access_token_surfaces_signing_key_misconfiguration(caplog): + config = AuthConfig( + enabled=True, + oidc=OIDCConfig( + workload_token_exchange_enabled=True, + workload_audience="nemo-platform", + ), + ) + with _test_client(config) as client, caplog.at_level(logging.ERROR, logger="nmp.core.auth.api.v2.authenticate"): + response = client.get( + "/authenticate", + headers={"Authorization": "Bearer signed.jwt.token"}, + ) + + assert response.status_code == 500 + assert response.json()["detail"] == "Workload token authentication is misconfigured" + assert "Failed to load workload access token signing key" in caplog.text + + +def test_authenticate_openapi_documents_error_responses(tmp_path): + config = AuthConfig(enabled=True, token_signing=TokenSigningConfig(private_key_file=str(tmp_path / "private.pem"))) + (tmp_path / "private.pem").write_bytes(_private_key_pem()) + with _test_client(config) as client: + openapi = cast(FastAPI, client.app).openapi() + + for method in ("get", "post"): + responses = openapi["paths"]["/authenticate"][method]["responses"] + assert responses["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AuthenticateResponse" + } + assert responses["401"]["description"] == "Missing or invalid bearer token" + assert responses["401"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AuthenticateErrorResponse" + } + assert responses["500"]["description"] == "Bearer token authentication is misconfigured" + assert responses["500"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AuthenticateErrorResponse" + } + + authenticate_schema = openapi["components"]["schemas"]["AuthenticateResponse"] + assert authenticate_schema["properties"]["email"]["nullable"] is True + assert authenticate_schema["properties"]["jti"]["nullable"] is True + assert authenticate_schema["properties"]["token_kind"]["enum"] == [ + "access_key", + "oidc_access_token", + "workload_access_token", + "workload_subject_token", + ] + + +def test_authenticate_rejects_missing_bearer_token(tmp_path): + config = AuthConfig(enabled=True, token_signing=TokenSigningConfig(private_key_file=str(tmp_path / "private.pem"))) + (tmp_path / "private.pem").write_bytes(_private_key_pem()) + with _test_client(config) as client: + response = client.post("/authenticate") + + assert response.status_code == 401 + assert response.json()["detail"] == "Missing bearer token" + + +def test_authenticate_rejects_malformed_bearer_token(tmp_path): + config = AuthConfig(enabled=True, token_signing=TokenSigningConfig(private_key_file=str(tmp_path / "private.pem"))) + (tmp_path / "private.pem").write_bytes(_private_key_pem()) + with _test_client(config) as client: + response = client.post("/authenticate", headers={"Authorization": "Bearer token extra"}) + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid bearer token" + + +def test_access_key_specific_authenticate_route_is_removed(tmp_path): + config = AuthConfig(enabled=True, token_signing=TokenSigningConfig(private_key_file=str(tmp_path / "private.pem"))) + (tmp_path / "private.pem").write_bytes(_private_key_pem()) + with _test_client(config) as client: + response = client.post("/v2/access-keys/authenticate") + + assert response.status_code == 404 diff --git a/services/core/auth/tests/test_workload_token_exchange.py b/services/core/auth/tests/test_workload_token_exchange.py index db587f9a15..93d2663809 100644 --- a/services/core/auth/tests/test_workload_token_exchange.py +++ b/services/core/auth/tests/test_workload_token_exchange.py @@ -3,16 +3,21 @@ import asyncio import json +from pathlib import Path from typing import Any +import nmp.common.auth.signing_keys as signing_keys_mod import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from fastapi import FastAPI from fastapi.testclient import TestClient +from jwt.algorithms import RSAAlgorithm +from nmp.common.auth.signing_keys import RSASigningKeyCache from nmp.common.config import AuthConfig, Configuration -from nmp.common.config.base import OIDCConfig +from nmp.common.config.base import AccessKeyConfig, OIDCConfig, TokenSigningConfig from nmp.core.auth.api.v2 import workload_token_exchange as exchange +from pydantic import ValidationError @pytest.fixture(autouse=True) @@ -39,6 +44,12 @@ def _private_key_pem(private_key: rsa.RSAPrivateKey) -> str: ).decode() +def _openapi(client: TestClient) -> dict[str, Any]: + app = client.app + assert isinstance(app, FastAPI) + return app.openapi() + + @pytest.fixture def exchange_config(workload_signing_key: rsa.RSAPrivateKey, tmp_path) -> AuthConfig: private_key_file = tmp_path / "workload-token-private-key.pem" @@ -70,18 +81,119 @@ def client(exchange_config: AuthConfig, exchange_service: exchange.WorkloadToken return TestClient(app, raise_server_exceptions=False) +def _jwks_test_client(config: AuthConfig, exchange_service: exchange.WorkloadTokenExchangeService) -> TestClient: + Configuration.set_override(config) + app = FastAPI() + app.dependency_overrides[exchange.get_workload_token_exchange_service] = lambda: exchange_service + app.include_router(exchange.router) + return TestClient(app, raise_server_exceptions=False) + + def test_jwks_publishes_workload_exchange_signing_key(client: TestClient) -> None: response = client.get("/jwks") assert response.status_code == 200 keys = response.json()["keys"] - assert keys[0]["kid"] == "nemo-workload-exchange" + assert keys[0]["kid"] == "nemo-platform-signing" assert keys[0]["use"] == "sig" assert keys[0]["alg"] == "RS256" +def test_jwks_returns_empty_key_set_when_workload_exchange_and_access_keys_are_disabled( + exchange_service: exchange.WorkloadTokenExchangeService, +) -> None: + config = AuthConfig(enabled=True) + client = _jwks_test_client(config, exchange_service) + + response = client.get("/jwks") + + assert response.status_code == 200 + assert response.json() == {"keys": []} + + +def test_jwks_returns_only_access_key_signing_key_when_workload_exchange_is_disabled( + tmp_path: Path, + exchange_service: exchange.WorkloadTokenExchangeService, +) -> None: + access_key_private_key_file = tmp_path / "access-key-private.pem" + access_key_private_key_file.write_text( + _private_key_pem(rsa.generate_private_key(public_exponent=65537, key_size=2048)), + encoding="utf-8", + ) + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig( + key_id="access-key-signing", + private_key_file=str(access_key_private_key_file), + ), + access_keys=AccessKeyConfig(enabled=True), + ) + client = _jwks_test_client(config, exchange_service) + + response = client.get("/jwks") + + assert response.status_code == 200 + assert [key["kid"] for key in response.json()["keys"]] == ["access-key-signing"] + + +def test_jwks_includes_distinct_workload_and_access_key_signing_keys( + tmp_path: Path, + exchange_service: exchange.WorkloadTokenExchangeService, +) -> None: + workload_private_key_file = tmp_path / "workload-private.pem" + access_key_private_key_file = tmp_path / "access-key-private.pem" + workload_private_key_file.write_text( + _private_key_pem(rsa.generate_private_key(public_exponent=65537, key_size=2048)), + encoding="utf-8", + ) + access_key_private_key_file.write_text( + _private_key_pem(rsa.generate_private_key(public_exponent=65537, key_size=2048)), + encoding="utf-8", + ) + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig( + key_id="access-key-signing", + private_key_file=str(access_key_private_key_file), + ), + oidc=OIDCConfig( + enabled=True, + workload_token_exchange_enabled=True, + workload_token_key_id="workload-signing", + workload_token_private_key_file=str(workload_private_key_file), + ), + access_keys=AccessKeyConfig(enabled=True), + ) + client = _jwks_test_client(config, exchange_service) + + response = client.get("/jwks") + + assert response.status_code == 200 + assert [key["kid"] for key in response.json()["keys"]] == ["workload-signing", "access-key-signing"] + + +def test_jwks_deduplicates_shared_workload_and_access_key_signing_key( + exchange_config: AuthConfig, + exchange_service: exchange.WorkloadTokenExchangeService, +) -> None: + config = exchange_config.model_copy( + update={ + "token_signing": exchange_config.token_signing.model_copy( + update={"private_key_file": exchange_config.oidc.workload_token_private_key_file} + ), + "access_keys": AccessKeyConfig(enabled=True), + } + ) + client = _jwks_test_client(config, exchange_service) + + response = client.get("/jwks") + + assert response.status_code == 200 + assert [key["kid"] for key in response.json()["keys"]] == ["nemo-platform-signing"] + + def test_jwks_openapi_documents_jwks_response(client: TestClient) -> None: - openapi = client.app.openapi() + openapi = _openapi(client) operation = openapi["paths"]["/jwks"]["get"] assert operation["responses"]["200"]["content"]["application/json"]["schema"] == { @@ -101,7 +213,7 @@ def test_jwks_openapi_documents_jwks_response(client: TestClient) -> None: def test_token_exchange_openapi_documents_form_request_and_token_response(client: TestClient) -> None: - openapi = client.app.openapi() + openapi = _openapi(client) operation = openapi["paths"]["/token"]["post"] request_schema = operation["requestBody"]["content"]["application/x-www-form-urlencoded"]["schema"] @@ -251,6 +363,165 @@ def test_validated_audience_accepts_configured_allowlist(exchange_config: AuthCo assert exchange._validated_audience(exchange_config, "extra-audience") == "extra-audience" +def test_workload_signing_key_uses_shared_token_signing_when_workload_override_unset( + workload_signing_key: rsa.RSAPrivateKey, + tmp_path, +) -> None: + private_key_file = tmp_path / "platform-token-private-key.pem" + private_key_file.write_text(_private_key_pem(workload_signing_key), encoding="utf-8") + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig( + issuer="https://nmp.example.com/apis/auth", + key_id="nemo-platform-signing", + private_key_file=str(private_key_file), + ), + oidc=OIDCConfig( + enabled=True, + issuer="https://idp.example.com/application/o/nemo-cli/", + client_id="nemo-platform-cli", + workload_token_exchange_enabled=True, + ), + ) + + signing_key = exchange.WorkloadTokenExchangeService().workload_signing_key(config) + + assert signing_key.kid == "nemo-platform-signing" + + +def test_workload_exchange_requires_resolved_token_signing_key_id() -> None: + with pytest.raises(ValidationError, match="workload_token_key_id or auth.token_signing.key_id"): + AuthConfig( + enabled=True, + token_signing=TokenSigningConfig(key_id=""), + oidc=OIDCConfig( + enabled=True, + workload_token_exchange_enabled=True, + ), + ) + + +def test_workload_exchange_accepts_workload_key_id_when_shared_key_id_unset() -> None: + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig(key_id=""), + oidc=OIDCConfig( + enabled=True, + workload_token_exchange_enabled=True, + workload_token_key_id="workload-signing", + ), + ) + + assert config.oidc.workload_token_key_id == "workload-signing" + + +def test_workload_signing_key_specific_override_wins_over_shared_token_signing( + workload_signing_key: rsa.RSAPrivateKey, + tmp_path, +) -> None: + shared_private_key_file = tmp_path / "platform-token-private-key.pem" + workload_private_key_file = tmp_path / "workload-token-private-key.pem" + shared_private_key_file.write_text(_private_key_pem(workload_signing_key), encoding="utf-8") + workload_private_key_file.write_text(_private_key_pem(workload_signing_key), encoding="utf-8") + config = AuthConfig( + enabled=True, + token_signing=TokenSigningConfig( + issuer="https://nmp.example.com/apis/auth", + key_id="nemo-platform-signing", + private_key_file=str(shared_private_key_file), + ), + oidc=OIDCConfig( + enabled=True, + issuer="https://idp.example.com/application/o/nemo-cli/", + client_id="nemo-platform-cli", + workload_token_exchange_enabled=True, + workload_token_key_id="nemo-workload-exchange", + workload_token_private_key_file=str(workload_private_key_file), + ), + ) + + signing_key = exchange.WorkloadTokenExchangeService().workload_signing_key(config) + + assert signing_key.kid == "nemo-workload-exchange" + + +def test_workload_signing_key_reuses_cached_private_key_file( + exchange_config: AuthConfig, + exchange_service: exchange.WorkloadTokenExchangeService, + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_load = signing_keys_mod._load_rsa_signing_key_async + load_count = 0 + + async def counted_load(**kwargs: Any) -> signing_keys_mod.RSASigningKey: + nonlocal load_count + load_count += 1 + return await original_load(**kwargs) + + monkeypatch.setattr(signing_keys_mod, "_load_rsa_signing_key_async", counted_load) + + signing_key = exchange_service.workload_signing_key(exchange_config) + public_jwk = exchange_service.public_jwk(exchange_config) + signing_key_again = exchange_service.workload_signing_key(exchange_config) + + assert signing_key_again is signing_key + assert public_jwk["kid"] == signing_key.kid + assert load_count == 1 + + +class _AsyncOnlySigningKeyCache(RSASigningKeyCache): + def __init__(self) -> None: + super().__init__() + self.calls: list[dict[str, Any]] = [] + + def public_jwk_from_file( + self, + *, + kid: str, + private_key_file: str | None, + missing_private_key_message: str, + invalid_private_key_message: str, + ) -> dict[str, Any]: + raise AssertionError("sync public JWK path was called") + + async def public_jwk_from_file_async( + self, + *, + kid: str, + private_key_file: str | None, + missing_private_key_message: str, + invalid_private_key_message: str, + ) -> dict[str, Any]: + self.calls.append( + { + "kid": kid, + "private_key_file": private_key_file, + "missing_private_key_message": missing_private_key_message, + "invalid_private_key_message": invalid_private_key_message, + } + ) + return {"kid": kid, "use": "sig", "alg": "RS256"} + + +def test_auth_jwks_response_uses_async_workload_public_jwk_path(exchange_config: AuthConfig) -> None: + signing_key_cache = _AsyncOnlySigningKeyCache() + exchange_service = exchange.WorkloadTokenExchangeService(signing_key_cache=signing_key_cache) + + response = asyncio.run(exchange.auth_jwks_response(exchange_config, exchange_service)) + + assert [key.model_dump() for key in response.keys] == [ + {"kid": "nemo-platform-signing", "use": "sig", "alg": "RS256"} + ] + assert signing_key_cache.calls == [ + { + "kid": "nemo-platform-signing", + "private_key_file": exchange_config.oidc.workload_token_private_key_file, + "missing_private_key_message": "auth.token_signing.private_key_file must be configured for workload token exchange", + "invalid_private_key_message": "workload token private key must be an RSA private key", + } + ] + + class _FakeResponse: def __init__(self, payload: dict[str, Any]) -> None: self._payload = payload @@ -287,7 +558,7 @@ def _signed_subject_token( def _public_jwk_for_key(private_key: rsa.RSAPrivateKey, *, key_id: str) -> dict[str, Any]: - jwk = json.loads(exchange.RSAAlgorithm.to_jwk(private_key.public_key())) + jwk = json.loads(RSAAlgorithm.to_jwk(private_key.public_key())) jwk.update({"kid": key_id, "use": "sig", "alg": "RS256"}) return jwk @@ -298,6 +569,7 @@ def _mock_subject_jwks_client( monkeypatch: pytest.MonkeyPatch, ) -> dict[str, Any]: captured: dict[str, Any] = {"request_count": 0} + jwks = {"keys": [exchange_service.public_jwk(config)]} class FakeAsyncClient: def __init__(self, *, timeout: float) -> None: @@ -312,7 +584,7 @@ async def __aexit__(self, exc_type, exc, traceback) -> None: async def get(self, url: str) -> _FakeResponse: captured["request_count"] += 1 captured["url"] = url - return _FakeResponse({"keys": [exchange_service.public_jwk(config)]}) + return _FakeResponse(jwks) monkeypatch.setattr(exchange.httpx, "AsyncClient", FakeAsyncClient) return captured diff --git a/tests/auth_idp/authentik_live.py b/tests/auth_idp/authentik_live.py index 545a2f1a28..1a6cda907d 100644 --- a/tests/auth_idp/authentik_live.py +++ b/tests/auth_idp/authentik_live.py @@ -158,6 +158,9 @@ def prepare_authentik_compose_inputs(*, root: Path = AUTHENTIK_ROOT) -> None: "contrib/auth/authentik/config/platform-compose-authentik.yaml", { "auth": { + "access_keys": { + "enabled": True, + }, "oidc": { "additional_issuers": [ "http://authentik-server:9000/application/o/nemo/", @@ -171,7 +174,7 @@ def prepare_authentik_compose_inputs(*, root: Path = AUTHENTIK_ROOT) -> None: "https://nemo-gateway:8080/application/o/nemo-workload/", "${gateway_url}/application/o/nemo-workload/", ], - } + }, }, }, { diff --git a/tests/auth_idp/contracts/test_access_keys.py b/tests/auth_idp/contracts/test_access_keys.py new file mode 100644 index 0000000000..5651962e77 --- /dev/null +++ b/tests/auth_idp/contracts/test_access_keys.py @@ -0,0 +1,235 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import time +import uuid + +import httpx +import pytest +from nemo_platform_ext.client.tls import client_verify_from_env +from nmp.testing import grant_workspace_role + +from tests.auth_idp.common import jwt_claims, require_capability + +pytestmark = [ + pytest.mark.auth_idp, + pytest.mark.auth_idp_runtime, + pytest.mark.e2e, + pytest.mark.xdist_group("idp-live"), +] + +REQUEST_TIMEOUT_SECONDS = 10.0 +ROLE_GRANT_RETRY_TIMEOUT_SECONDS = 10.0 +ROLE_GRANT_RETRY_SLEEP_SECONDS = 0.5 + + +def _runtime_verify(auth_idp_runtime) -> str | bool: + return getattr(auth_idp_runtime, "verify", client_verify_from_env()) + + +def _create_access_key_with_body(auth_idp_runtime, bearer_token: str, body: dict[str, object]) -> dict[str, object]: + response = httpx.post( + f"{auth_idp_runtime.gateway_base_url}/apis/auth/v2/access-keys", + json=body, + headers={"Authorization": f"Bearer {bearer_token}"}, + timeout=REQUEST_TIMEOUT_SECONDS, + verify=_runtime_verify(auth_idp_runtime), + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["token_type"] == "Bearer" + assert body["token"] + return body + + +def _create_access_key(auth_idp_runtime, bearer_token: str) -> dict[str, object]: + return _create_access_key_with_body( + auth_idp_runtime, + bearer_token, + {"name": f"auth-idp-contract-{uuid.uuid4().hex[:8]}", "expires_in_seconds": 600}, + ) + + +def _tamper_jwt(token: str) -> str: + """Return a JWT with its signature bytes changed so validation must fail.""" + parts = token.split(".") + assert len(parts) == 3 + signature = parts[2] + replacement = "A" if signature[0] != "A" else "B" + parts[2] = f"{replacement}{signature[1:]}" + return ".".join(parts) + + +def _claim_values(value: object) -> set[str]: + if isinstance(value, list): + return {item for item in value if isinstance(item, str)} + if isinstance(value, str): + return {item.strip() for item in value.split(",") if item.strip()} + return set() + + +def _get_until_workspace_role_grant_applies( + url: str, + *, + headers: dict[str, str], + verify: str | bool, +) -> httpx.Response: + deadline = time.monotonic() + ROLE_GRANT_RETRY_TIMEOUT_SECONDS + while True: + response = httpx.get( + url, + headers=headers, + timeout=REQUEST_TIMEOUT_SECONDS, + verify=verify, + ) + if response.status_code == 200: + return response + + remaining = deadline - time.monotonic() + if remaining <= 0: + return response + time.sleep(min(ROLE_GRANT_RETRY_SLEEP_SECONDS, remaining)) + + +def test_provider_platform_access_key_authenticates_and_uses_workspace_rbac( + auth_idp_case, + auth_idp_runtime, + auth_idp_workspace, +): + require_capability(auth_idp_case, "platform_access_keys") + require_capability(auth_idp_case, "workload_provider_token") + require_capability(auth_idp_case, "workspace_rbac") + + workload_token = auth_idp_runtime.workload_provider_token() + created = _create_access_key(auth_idp_runtime, workload_token.access_token) + access_key = str(created["token"]) + access_key_claims = jwt_claims(access_key) + verify = _runtime_verify(auth_idp_runtime) + + assert created["principal"] == workload_token.claims["sub"] + assert access_key_claims["nmp_token_type"] == "access_key" + assert access_key_claims["sub"] == workload_token.claims["sub"] + assert access_key_claims["aud"] == "nemo-platform-access-key" + access_key_headers = {"Authorization": f"Bearer {access_key}"} + + authenticate_response = httpx.get( + f"{auth_idp_runtime.gateway_base_url}/apis/auth/authenticate", + headers=access_key_headers, + timeout=REQUEST_TIMEOUT_SECONDS, + verify=verify, + ) + authenticate_response.raise_for_status() + authenticated = authenticate_response.json() + assert authenticated["jti"] == created["jti"] + assert authenticated["principal"] == created["principal"] + assert authenticated["token_kind"] == "access_key" + + workspace_url = f"{auth_idp_runtime.gateway_base_url}/apis/entities/v2/workspaces/{auth_idp_workspace}" + denied_response = httpx.get( + workspace_url, + headers=access_key_headers, + timeout=REQUEST_TIMEOUT_SECONDS, + verify=verify, + ) + assert denied_response.status_code == 403, denied_response.text + + e2e_setup_sdk = auth_idp_runtime.e2e_setup_sdk() + role_principals = sorted(_claim_values(access_key_claims.get("groups"))) or [str(created["principal"])] + for principal in role_principals: + grant_workspace_role(e2e_setup_sdk, workspace=auth_idp_workspace, principal=principal, roles=["Viewer"]) + + allowed_response = _get_until_workspace_role_grant_applies( + workspace_url, + headers=access_key_headers, + verify=verify, + ) + assert allowed_response.status_code == 200, allowed_response.text + assert allowed_response.headers.get("x-envoy-upstream-service-time") is not None + assert allowed_response.json()["name"] == auth_idp_workspace + + +def test_provider_platform_access_key_defaults_expiry_when_omitted(auth_idp_runtime, auth_idp_case): + require_capability(auth_idp_case, "platform_access_keys") + require_capability(auth_idp_case, "workload_provider_token") + + workload_token = auth_idp_runtime.workload_provider_token() + + created = _create_access_key_with_body( + auth_idp_runtime, + workload_token.access_token, + {"name": f"default-expiry-{uuid.uuid4().hex[:8]}"}, + ) + access_key_claims = jwt_claims(str(created["token"])) + + assert "exp" in access_key_claims + assert created["expires_at"] is not None + + +def test_provider_platform_access_key_rejects_invalid_key( + auth_idp_case, + auth_idp_runtime, +): + require_capability(auth_idp_case, "platform_access_keys") + require_capability(auth_idp_case, "workload_provider_token") + + workload_token = auth_idp_runtime.workload_provider_token() + created = _create_access_key(auth_idp_runtime, workload_token.access_token) + invalid_access_key = _tamper_jwt(str(created["token"])) + invalid_access_key_headers = {"Authorization": f"Bearer {invalid_access_key}"} + verify = _runtime_verify(auth_idp_runtime) + + authenticate_response = httpx.get( + f"{auth_idp_runtime.gateway_base_url}/apis/auth/authenticate", + headers=invalid_access_key_headers, + timeout=REQUEST_TIMEOUT_SECONDS, + verify=verify, + ) + assert authenticate_response.status_code == 401, authenticate_response.text + + protected_response = httpx.get( + f"{auth_idp_runtime.gateway_base_url}/apis/entities/v2/workspaces", + headers=invalid_access_key_headers, + timeout=REQUEST_TIMEOUT_SECONDS, + verify=verify, + ) + assert protected_response.status_code == 401, protected_response.text + + +def test_provider_platform_access_key_ignores_spoofed_principal_headers( + auth_idp_case, + auth_idp_runtime, +): + require_capability(auth_idp_case, "platform_access_keys") + require_capability(auth_idp_case, "workload_provider_token") + require_capability(auth_idp_case, "spoofed_header_rejection") + + workload_token = auth_idp_runtime.workload_provider_token() + created = _create_access_key(auth_idp_runtime, workload_token.access_token) + access_key_headers = { + "Authorization": f"Bearer {created['token']}", + "X-NMP-Principal-Id": "service:bootstrap", + "X-NMP-Principal-Email": "attacker@example.com", + } + workspace_name = f"access-key-spoof-{uuid.uuid4().hex[:8]}" + verify = _runtime_verify(auth_idp_runtime) + + try: + create_response = httpx.post( + f"{auth_idp_runtime.gateway_base_url}/apis/entities/v2/workspaces", + json={"name": workspace_name, "description": "Access-key spoofed header check"}, + headers=access_key_headers, + timeout=REQUEST_TIMEOUT_SECONDS, + verify=verify, + ) + create_response.raise_for_status() + + created_by = create_response.json()["created_by"] + assert created_by == created["principal"] + assert created_by not in {"service:bootstrap", "attacker@example.com"} + finally: + httpx.delete( + f"{auth_idp_runtime.gateway_base_url}/apis/entities/v2/workspaces/{workspace_name}", + headers=access_key_headers, + timeout=REQUEST_TIMEOUT_SECONDS, + verify=verify, + ) diff --git a/tests/auth_idp/runtime_kubernetes.py b/tests/auth_idp/runtime_kubernetes.py index e16c387ac2..ccfdce3d77 100644 --- a/tests/auth_idp/runtime_kubernetes.py +++ b/tests/auth_idp/runtime_kubernetes.py @@ -503,6 +503,8 @@ def _helm_upgrade_args(context: str, kubeconfig: Path | None = None) -> list[str f"nemo-platform.platformConfig.platform.image_registry={registry}", "--set-string", f"nemo-platform.platformConfig.platform.image_tag={tag}", + "--set-string", + "nemo-platform.platformConfig.auth.access_keys.enabled=true", ], kubeconfig, ) diff --git a/tests/auth_idp/static/test_authentik_kubernetes_demo.py b/tests/auth_idp/static/test_authentik_kubernetes_demo.py index 20b90bff1f..81588ef733 100644 --- a/tests/auth_idp/static/test_authentik_kubernetes_demo.py +++ b/tests/auth_idp/static/test_authentik_kubernetes_demo.py @@ -196,6 +196,28 @@ def test_authentik_tutorial_grants_workloads_job_log_permissions() -> None: assert "permission to upload workload" in tutorial +def test_authentik_tutorial_tests_scoped_access_keys() -> None: + tutorial = (AUTHENTIK_DIR / "tutorial.md").read_text(encoding="utf-8") + + assert "NeMo Scoped Access Keys through the Authentik gateway." in tutorial + assert "--set nemo-platform.platformConfig.auth.access_keys.enabled=true" in tutorial + assert 'ACCESS_KEY="$(uv run nemo --context "$AUTHENTIK_CONTEXT" auth access-keys create \\' in tutorial + assert '--name "authentik-reference-${AUTHENTIK_RUNTIME}" \\' in tutorial + assert "--expires-in 600" in tutorial + assert '"${AUTHENTIK_BASE_URL}/apis/auth/authenticate"' in tutorial + assert '"${AUTHENTIK_BASE_URL}/apis/entities/v2/workspaces/${WORKSPACE}"' in tutorial + assert 'ACCESS_KEY_CONTEXT="${AUTHENTIK_CONTEXT}-access-key"' in tutorial + assert "uv run nemo config set \\" in tutorial + assert '--context "$ACCESS_KEY_CONTEXT" \\' in tutorial + assert '--base-url "$AUTHENTIK_BASE_URL" \\' in tutorial + assert '--access-token "$ACCESS_KEY" \\' in tutorial + assert '--workspace "$WORKSPACE"' in tutorial + assert 'uv run nemo --context "$ACCESS_KEY_CONTEXT" workspaces get "$WORKSPACE"' in tutorial + assert 'uv run nemo config use-context "$AUTHENTIK_CONTEXT"' in tutorial + assert 'test "$INVALID_STATUS" = "401"' in tutorial + assert "unset ACCESS_KEY ACCESS_KEY_CONTEXT INVALID_ACCESS_KEY INVALID_STATUS" in tutorial + + def test_authentik_e2e_ci_requires_published_nmp_api_image() -> None: ci_workflow = Path(".github/workflows/ci.yaml").read_text(encoding="utf-8") job = _workflow_job_block(ci_workflow, "python-auth-idp-e2e-test") @@ -319,6 +341,13 @@ def test_authentik_umbrella_values_define_one_shared_postgresql_instance() -> No nemo_database = values["nemo-platform"]["externalDatabase"] assert values["nemo-platform"]["postgresql"]["enabled"] is False + assert values["nemo-platform"]["clickhouse"]["enabled"] is False + assert values["nemo-platform"]["externalClickhouse"] == { + "host": "unused-clickhouse", + "existingSecret": "shared-postgresql", + "existingSecretPasswordKey": "nemo-password", + } + assert values["nemo-platform"]["api"]["extraArgs"] == ["--service-group=core"] assert nemo_database == { "host": "shared-postgresql", "port": 5432, @@ -716,14 +745,6 @@ def test_authentik_umbrella_values_configure_nemo_envoy_as_the_only_edge_proxy() "append_action": "OVERWRITE_IF_EXISTS_OR_ADD", } ] - jwt_filter = next( - filter_config - for filter_config in http_manager["http_filters"] - if filter_config["name"] == "envoy.filters.http.jwt_authn" - ) - jwt_providers = jwt_filter["typed_config"]["providers"] - clusters = {cluster["name"]: cluster for cluster in envoy_config["static_resources"]["clusters"]} - assert envoy["configOverride"] == '{{ include "nemo-platform-authentik.envoyConfig" . }}' gateway_ready_route = next(route for route in routes if route["match"] == {"path": "/health/gateway/ready"}) health_route = next(route for route in routes if route["match"] == {"prefix": "/health/"}) @@ -755,31 +776,68 @@ def test_authentik_umbrella_values_configure_nemo_envoy_as_the_only_edge_proxy() 'gateway_ready_http_call(request_handle, "authentik", "authentik-server", ' '"/application/o/nemo/.well-known/openid-configuration")' ) in lua_code - assert jwt_providers["authentik_workload"]["remote_jwks"]["http_uri"] == { - "uri": "https://nemo-platform-envoy:8080/application/o/nemo/jwks/", - "cluster": "nemo_envoy_https", + assert 'headers:remove("x-nmp-authorized")' in lua_code + assert 'headers:remove("x-nmp-scopes")' in lua_code + + assert "claim_to_headers" not in yaml.safe_dump(http_manager) + assert all( + filter_config["name"] != "envoy.filters.http.jwt_authn" for filter_config in http_manager["http_filters"] + ) + + ext_authz_filter = next( + filter_config + for filter_config in http_manager["http_filters"] + if filter_config["name"] == "envoy.filters.http.ext_authz" + ) + ext_authz = ext_authz_filter["typed_config"] + assert ext_authz["transport_api_version"] == "V3" + assert ext_authz["failure_mode_allow"] is False + assert ext_authz["http_service"]["path_prefix"] == "/apis/auth/authenticate" + assert ext_authz["http_service"]["server_uri"] == { + "uri": "http://nemo-platform-api:8080", + "cluster": "nemo", "timeout": "5s", } - assert jwt_providers["workload_exchange"]["remote_jwks"]["http_uri"] == { - "uri": "https://nemo-platform-envoy:8080/apis/auth/jwks", - "cluster": "nemo_envoy_https", - "timeout": "5s", + allowed_headers = ext_authz["http_service"]["authorization_response"]["allowed_upstream_headers"]["patterns"] + assert {"exact": "x-nmp-principal-id"} in allowed_headers + assert {"exact": "x-nmp-principal-email"} in allowed_headers + assert {"exact": "x-nmp-principal-groups"} in allowed_headers + assert {"exact": "x-nmp-scopes"} in allowed_headers + + protected_api_route = next(route for route in routes if route["match"] == {"prefix": "/apis/"}) + assert "typed_per_filter_config" not in protected_api_route + + public_authenticate_route = next(route for route in routes if route["match"] == {"path": "/apis/auth/authenticate"}) + assert public_authenticate_route["typed_per_filter_config"]["envoy.filters.http.ext_authz"] == { + "@type": "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute", + "disabled": True, } - envoy_jwks_cluster = clusters["nemo_envoy_https"] - assert envoy_jwks_cluster["transport_socket"]["typed_config"]["common_tls_context"]["validation_context"][ - "trusted_ca" - ] == {"filename": "/etc/nmp/workload-token-tls/ca.crt"} platform_config = nemo_values["platformConfig"].get("platform", {}) assert "base_url" not in platform_config assert "auth" not in platform_config.get("service_discovery", {}) + token_signing = nemo_values["platformConfig"]["auth"]["token_signing"] + assert token_signing == { + "issuer": f"{ENVOY_SERVICE_URL_TEMPLATE}/apis/auth", + "key_id": "nemo-platform-signing", + "private_key_file": "/etc/nmp/workload-token/private-key.pem", + } + assert nemo_values["platformConfig"]["auth"]["access_keys"] == {"enabled": False} oidc = nemo_values["platformConfig"]["auth"]["oidc"] assert oidc["issuer"] == f"{AUTHENTIK_SERVICE_URL_TEMPLATE}/application/o/nemo-cli/" assert oidc["additional_issuers"][0] == f"{AUTHENTIK_SERVICE_URL_TEMPLATE}/application/o/nemo/" assert oidc["additional_issuers"][1] == f"{PUBLIC_GATEWAY_URL_TEMPLATE}/application/o/nemo-cli/" assert oidc["additional_issuers"][2] == f"{PUBLIC_GATEWAY_URL_TEMPLATE}/application/o/nemo/" - assert oidc["workload_token_issuer"] == f"{ENVOY_SERVICE_URL_TEMPLATE}/apis/auth" assert oidc["workload_token_endpoint"] == f"{ENVOY_SERVICE_URL_TEMPLATE}/apis/auth/token" + assert oidc["workload_subject_jwks_uri"] == f"{AUTHENTIK_SERVICE_URL_TEMPLATE}/application/o/nemo-workload/jwks/" + assert oidc["workload_subject_issuers"] == [ + f"{AUTHENTIK_SERVICE_URL_TEMPLATE}/application/o/nemo-workload/", + f"{ENVOY_SERVICE_URL_TEMPLATE}/application/o/nemo-workload/", + f"{PUBLIC_GATEWAY_URL_TEMPLATE}/application/o/nemo-workload/", + ] + assert "workload_token_issuer" not in oidc + assert "workload_token_key_id" not in oidc + assert "workload_token_private_key_file" not in oidc assert oidc["token_endpoint"] == f"{PUBLIC_GATEWAY_URL_TEMPLATE}/application/o/token/" assert oidc["device_authorization_endpoint"] == f"{PUBLIC_GATEWAY_URL_TEMPLATE}/application/o/device/" assert nemo_values["authentikPublicGateway"] == { @@ -810,13 +868,16 @@ def test_authentik_umbrella_values_mount_workload_token_signing_key_as_file() -> values = _load_yaml(HELM_DIR / "values.yaml") signing_key = values["workloadTokenSigningKey"] nemo_values = values["nemo-platform"] - oidc = nemo_values["platformConfig"]["auth"]["oidc"] + token_signing = nemo_values["platformConfig"]["auth"]["token_signing"] assert signing_key["secretName"] == "nemo-workload-token-signing-key" assert signing_key["key"] == "private-key.pem" assert signing_key["mountPath"] == "/etc/nmp/workload-token" assert signing_key["privateKeyPem"] == "" - assert oidc["workload_token_private_key_file"] == "/etc/nmp/workload-token/private-key.pem" + assert token_signing["private_key_file"] == "/etc/nmp/workload-token/private-key.pem" + assert nemo_values["api"]["env"]["NMP_AUTH_TOKEN_SIGNING__PRIVATE_KEY_FILE"] == ( + "/etc/nmp/workload-token/private-key.pem" + ) assert nemo_values["api"]["extraVolumes"] == [ { @@ -926,6 +987,7 @@ def test_authentik_kubernetes_runner_uses_helm_not_kustomize() -> None: assert "NMP_AUTHENTIK_K8S_GATEWAY_PORT=${K8S_GATEWAY_PORT}" in run_sh assert "NMP_AUTHENTIK_K8S_GATEWAY_PORT" in runtime_impl assert "nemo-platform.authentikPublicGateway.port=" in runtime_impl + assert "nemo-platform.platformConfig.auth.access_keys.enabled=true" in runtime_impl assert "GITHUB_TOKEN: ${{ inputs['kind-image-pull-token'] }}" in setup_kind_action assert "CERT_MANAGER_CHART" not in runtime_impl assert "_install_cert_manager" not in runtime_impl diff --git a/tests/auth_idp/static/test_provider_layout.py b/tests/auth_idp/static/test_provider_layout.py index 837f373352..688e791f30 100644 --- a/tests/auth_idp/static/test_provider_layout.py +++ b/tests/auth_idp/static/test_provider_layout.py @@ -57,6 +57,10 @@ def test_authentik_compose_defaults_support_direct_docker_compose_start(): assert compose["name"] == "${COMPOSE_PROJECT_NAME:-nemo-platform-authentik}" assert compose["x-authentik-env"]["AUTHENTIK_WORKLOAD_IDENTITY_PASSWORD"] == password_default assert compose["services"]["nemo"]["environment"]["AUTHENTIK_WORKLOAD_IDENTITY_PASSWORD"] == password_default + assert ( + compose["services"]["nemo"]["environment"]["NMP_AUTH_TOKEN_SIGNING__PRIVATE_KEY_FILE"] + == "/var/run/secrets/nemo-platform/workload-token-signing/private-key.pem" + ) assert ( "../.generated/workload-token-private-key.pem:" "/var/run/secrets/nemo-platform/workload-token-signing/private-key.pem:ro" @@ -134,23 +138,43 @@ def test_authentik_compose_uses_liveness_for_container_health_and_routes_status_ 'gateway_ready_http_call(request_handle, "authentik", "authentik-server", ' '"/application/o/nemo/.well-known/openid-configuration")' ) in lua_code + assert 'headers:remove("x-nmp-authorized")' in lua_code + assert 'headers:remove("x-nmp-scopes")' in lua_code - jwt_filter = next( + assert "claim_to_headers" not in yaml.safe_dump(http_manager) + assert all( + filter_config["name"] != "envoy.filters.http.jwt_authn" for filter_config in http_manager["http_filters"] + ) + + ext_authz_filter = next( filter_config for filter_config in http_manager["http_filters"] - if filter_config["name"] == "envoy.filters.http.jwt_authn" + if filter_config["name"] == "envoy.filters.http.ext_authz" ) - jwt_providers = jwt_filter["typed_config"]["providers"] - assert jwt_providers["authentik_workload"]["audiences"] == [ - "nemo-platform", - "nemo-platform-cli", - "nemo-platform-workload", - ] - assert jwt_providers["workload_exchange"]["audiences"] == ["nemo-platform"] - jwt_rules = jwt_filter["typed_config"]["rules"] - jwt_rule_matches = [rule["match"] for rule in jwt_rules] - assert {"prefix": "/health/"} in jwt_rule_matches - assert {"path": "/status"} in jwt_rule_matches + ext_authz = ext_authz_filter["typed_config"] + assert ext_authz["@type"] == "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz" + assert ext_authz["transport_api_version"] == "V3" + assert ext_authz["failure_mode_allow"] is False + assert ext_authz["http_service"]["path_prefix"] == "/apis/auth/authenticate" + assert ext_authz["http_service"]["server_uri"] == { + "uri": "http://nemo:8080", + "cluster": "nemo", + "timeout": "5s", + } + allowed_headers = ext_authz["http_service"]["authorization_response"]["allowed_upstream_headers"]["patterns"] + assert {"exact": "x-nmp-principal-id"} in allowed_headers + assert {"exact": "x-nmp-principal-email"} in allowed_headers + assert {"exact": "x-nmp-principal-groups"} in allowed_headers + assert {"exact": "x-nmp-scopes"} in allowed_headers + + protected_api_route = next(route for route in routes if route["match"] == {"prefix": "/apis/"}) + assert "typed_per_filter_config" not in protected_api_route + + public_authenticate_route = next(route for route in routes if route["match"] == {"path": "/apis/auth/authenticate"}) + assert public_authenticate_route["typed_per_filter_config"]["envoy.filters.http.ext_authz"] == { + "@type": "type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute", + "disabled": True, + } def test_authentik_compose_mounts_workload_token_signing_key(): @@ -164,9 +188,10 @@ def test_authentik_compose_mounts_workload_token_signing_key(): assert key_mount in compose["services"]["nemo"]["volumes"] assert ( - config["auth"]["oidc"]["workload_token_private_key_file"] + config["auth"]["token_signing"]["private_key_file"] == "/var/run/secrets/nemo-platform/workload-token-signing/private-key.pem" ) + assert "workload_token_private_key_file" not in config["auth"]["oidc"] def test_authentik_compose_uses_https_gateway_for_workloads(): @@ -183,7 +208,10 @@ def test_authentik_compose_uses_https_gateway_for_workloads(): assert "loopback_address" not in config["platform"] assert "service_discovery" not in config["platform"] assert config["auth"]["oidc"]["token_endpoint"] == "https://127.0.0.1:18080/application/o/token/" - assert config["auth"]["oidc"]["workload_token_issuer"] == "https://nemo-gateway:8080/apis/auth" + assert config["auth"]["token_signing"]["issuer"] == "https://nemo-gateway:8080/apis/auth" + assert config["auth"]["token_signing"]["key_id"] == "nemo-platform-signing" + assert config["auth"]["access_keys"]["enabled"] is True + assert "workload_token_issuer" not in config["auth"]["oidc"] assert config["auth"]["oidc"]["workload_token_endpoint"] == "https://nemo-gateway:8080/apis/auth/token" assert ( "https://nemo-gateway:8080/application/o/nemo-workload/" in config["auth"]["oidc"]["workload_subject_issuers"] @@ -239,3 +267,12 @@ def test_authentik_compose_mounts_gateway_ca_into_docker_workloads(): "mount_path": "/etc/nmp/gateway-tls", } ] + + +def test_authentik_runtimes_declare_platform_access_key_capability(): + manifest = yaml.safe_load(Path("contrib/auth/authentik/manifest.yaml").read_text()) + + runtimes = {runtime["id"]: runtime for runtime in manifest["test_runtimes"]} + + assert "platform_access_keys" in runtimes["authentik-compose"]["capabilities"] + assert "platform_access_keys" in runtimes["authentik-kubernetes"]["capabilities"] diff --git a/tests/unit/test_helm_clickhouse.py b/tests/unit/test_helm_clickhouse.py new file mode 100644 index 0000000000..49f6895357 --- /dev/null +++ b/tests/unit/test_helm_clickhouse.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).parent.parent.parent +HELM_DIR = ROOT / "k8s" / "helm" +HELM_TEMPLATE_TIMEOUT_SECONDS = 60 + + +def _helm_template(*args: str) -> list[dict]: + if shutil.which("helm") is None: + pytest.skip("helm is required to render the NeMo Platform chart") + + completed = subprocess.run( + ["helm", "template", "nemo-platform", str(HELM_DIR), *args], + check=True, + capture_output=True, + text=True, + timeout=HELM_TEMPLATE_TIMEOUT_SECONDS, + ) + return [document for document in yaml.safe_load_all(completed.stdout) if document] + + +def _clickhouse_resources(documents: list[dict]) -> list[dict]: + return [ + document + for document in documents + if document.get("metadata", {}).get("name") == "nemo-platform-clickhouse" + or document.get("metadata", {}).get("labels", {}).get("app.kubernetes.io/component") == "clickhouse" + ] + + +def _api_container(documents: list[dict]) -> dict: + deployment = next( + document + for document in documents + if document["kind"] == "Deployment" and document["metadata"]["name"] == "nemo-platform-api" + ) + return deployment["spec"]["template"]["spec"]["containers"][0] + + +def _env_by_name(container: dict) -> dict[str, dict]: + return {env["name"]: env for env in container["env"]} + + +def test_default_intake_selection_renders_embedded_clickhouse_dependency() -> None: + documents = _helm_template() + + clickhouse_resources = _clickhouse_resources(documents) + assert {document["kind"] for document in clickhouse_resources} >= {"Secret", "Service", "StatefulSet"} + + env = _env_by_name(_api_container(documents)) + assert env["NMP_INTAKE_CLICKHOUSE_URL"]["value"] == "http://nemo-platform-clickhouse:8123" + + +def test_core_service_group_can_skip_embedded_clickhouse_dependency() -> None: + documents = _helm_template( + "--set", + "clickhouse.enabled=false", + "--set", + "externalClickhouse.host=unused-clickhouse", + "--set", + "externalClickhouse.existingSecret=shared-postgresql", + "--set", + "externalClickhouse.existingSecretPasswordKey=nemo-password", + "--set-string", + "api.extraArgs[0]=--service-group=core", + ) + + assert _clickhouse_resources(documents) == [] + assert "--service-group=core" in _api_container(documents)["args"] + + +def test_external_clickhouse_disables_embedded_dependency() -> None: + documents = _helm_template( + "--set", + "clickhouse.enabled=false", + "--set", + "externalClickhouse.host=clickhouse.example.internal", + "--set", + "externalClickhouse.existingSecret=clickhouse-credentials", + "--set", + "externalClickhouse.existingSecretPasswordKey=password", + ) + + assert _clickhouse_resources(documents) == [] + + env = _env_by_name(_api_container(documents)) + assert env["NMP_INTAKE_CLICKHOUSE_URL"]["value"] == "http://clickhouse.example.internal:8123" + password_ref = env["NMP_INTAKE_CLICKHOUSE_PASSWORD"]["valueFrom"]["secretKeyRef"] + assert password_ref == {"name": "clickhouse-credentials", "key": "password"} diff --git a/tools/nemo-platform-sdk-tools/src/nemo_platform_sdk_tools/sdk/cli_generator/cli_config.yaml b/tools/nemo-platform-sdk-tools/src/nemo_platform_sdk_tools/sdk/cli_generator/cli_config.yaml index 3bdb398ed6..1f2a6510cb 100644 --- a/tools/nemo-platform-sdk-tools/src/nemo_platform_sdk_tools/sdk/cli_generator/cli_config.yaml +++ b/tools/nemo-platform-sdk-tools/src/nemo_platform_sdk_tools/sdk/cli_generator/cli_config.yaml @@ -15,6 +15,14 @@ defaults: # Root `nemo --help` metadata for generated API commands. top_level: + auth: + panel: Core plugins + help: Manage auth. + hidden: true + access-keys: + panel: Core plugins + help: Manage access keys. + hidden: true adapters: panel: Core plugins help: Manage adapters. @@ -57,6 +65,10 @@ top_level: # Resource-specific configurations config: +- resource: [auth] + skip: true +- resource: [access_keys] + skip: true - resource: [models] methods: list: