urun-cli 0.4.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: End-user CLI for deploying apps to urun
5
5
  Project-URL: Homepage, https://urun.sh
6
6
  Project-URL: Repository, https://github.com/urun-sh/urun-cli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "urun-cli"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "End-user CLI for deploying apps to urun"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -126,9 +126,21 @@ def add_auth_parser(sub: argparse._SubParsersAction) -> None:
126
126
  description=(
127
127
  "Register (or replace) the trusted key source the control plane "
128
128
  "verifies this org's session JWTs against. Provide EXACTLY ONE of "
129
- "--jwks-url (a remote JWK Set endpoint) or --jwks-json (an inline JWK "
130
- "Set from a file or '-' for stdin). Optionally pin the expected token "
131
- "issuer/audience. This registers trust only; it does not mint a JWT."
129
+ "--workos-client-id (templates the WorkOS AuthKit JWKS for you), "
130
+ "--jwks-url (a remote JWK Set endpoint), or --jwks-json (an inline "
131
+ "JWK Set from a file or '-' for stdin). Optionally pin the expected "
132
+ "token issuer/audience. This registers trust only; it does not mint a JWT."
133
+ ),
134
+ )
135
+ set_parser.add_argument(
136
+ "--workos-client-id",
137
+ metavar="CLIENT_ID",
138
+ help=(
139
+ "WorkOS Client ID (client_...). Templates the WorkOS AuthKit JWKS "
140
+ "endpoint and access-token issuer for you, so you do not have to "
141
+ "supply raw JWKS URLs: --jwks-url becomes "
142
+ "https://api.workos.com/sso/jwks/<client_id> and --issuer defaults to "
143
+ "https://api.workos.com. Override either with --issuer/--audience."
132
144
  ),
133
145
  )
134
146
  set_parser.add_argument(
@@ -142,7 +154,11 @@ def add_auth_parser(sub: argparse._SubParsersAction) -> None:
142
154
  )
143
155
  set_parser.add_argument(
144
156
  "--issuer",
145
- help="Optional expected JWT `iss` claim; tokens whose iss differs are rejected",
157
+ help=(
158
+ "Optional expected JWT `iss` claim; tokens whose iss differs are "
159
+ "rejected. With --workos-client-id this defaults to "
160
+ "https://api.workos.com (pass to override, e.g. a custom AuthKit domain)."
161
+ ),
146
162
  )
147
163
  set_parser.add_argument(
148
164
  "--audience",
@@ -229,38 +245,81 @@ def auth(args: argparse.Namespace) -> int:
229
245
  getattr(args, "auth_command", None) != "jwks"
230
246
  or getattr(args, "jwks_command", None) != "set"
231
247
  ):
232
- raise UrunError("usage: urun auth jwks set --jwks-url <url> | --jwks-json <file|->")
248
+ raise UrunError(
249
+ "usage: urun auth jwks set "
250
+ "--workos-client-id <client_...> | --jwks-url <url> | --jwks-json <file|->"
251
+ )
233
252
  return auth_jwks_set(args)
234
253
 
235
254
 
255
+ # WorkOS publishes one JWK Set per OAuth client at this unauthenticated endpoint;
256
+ # it hosts the public key used to verify AuthKit / User Management access tokens.
257
+ WORKOS_JWKS_URL_TEMPLATE = "https://api.workos.com/sso/jwks/{client_id}"
258
+ # A WorkOS AuthKit (User Management) access token's `iss` claim is the static
259
+ # WorkOS API host (NOT a per-client URL). It becomes your custom AuthKit domain
260
+ # only when one is configured, which is why --issuer can override this default.
261
+ WORKOS_ACCESS_TOKEN_ISSUER = "https://api.workos.com"
262
+ # A WorkOS client id looks like `client_<base32-ish>`.
263
+ WORKOS_CLIENT_ID_RE = re.compile(r"^client_[0-9A-Za-z]+$")
264
+
265
+
266
+ def workos_jwks_template(client_id: str) -> dict[str, str]:
267
+ """Template the WorkOS AuthKit trust anchor from just the WorkOS Client ID.
268
+
269
+ Returns the JWKS endpoint URL and the expected access-token issuer for the
270
+ given WorkOS client so callers never have to hand-build raw WorkOS URLs.
271
+
272
+ Note (deliberately omitted): WorkOS AuthKit *access tokens* do NOT carry an
273
+ `aud` claim by default, so no audience is templated — pin one with
274
+ --audience only if your AuthKit config actually sets it (pinning a wrong
275
+ audience would silently 401 every real token).
276
+ """
277
+ if not WORKOS_CLIENT_ID_RE.fullmatch(client_id):
278
+ raise UrunError(
279
+ "invalid WorkOS client id; expected client_<alphanumeric> (e.g. client_01ABC...)"
280
+ )
281
+ return {
282
+ "jwks_url": WORKOS_JWKS_URL_TEMPLATE.format(client_id=client_id),
283
+ "expected_issuer": WORKOS_ACCESS_TOKEN_ISSUER,
284
+ }
285
+
286
+
236
287
  def auth_jwks_set(args: argparse.Namespace) -> int:
237
288
  jwks_url = string_value(args.jwks_url)
238
289
  jwks_json_arg = string_value(args.jwks_json)
239
- if bool(jwks_url) == bool(jwks_json_arg):
240
- raise UrunError("provide exactly one of --jwks-url or --jwks-json")
290
+ workos_client_id = string_value(getattr(args, "workos_client_id", None))
291
+ issuer = string_value(args.issuer)
292
+ audience = string_value(args.audience)
293
+
294
+ sources = [s for s in (workos_client_id, jwks_url, jwks_json_arg) if s]
295
+ if len(sources) != 1:
296
+ raise UrunError("provide exactly one of --workos-client-id, --jwks-url, or --jwks-json")
241
297
 
242
298
  payload: dict[str, Any] = {}
243
- if jwks_url:
299
+ if workos_client_id:
300
+ # Template the WorkOS well-known structure from the client id so the
301
+ # caller never supplies raw URLs. --issuer/--audience still override.
302
+ template = workos_jwks_template(workos_client_id)
303
+ payload["jwks_url"] = template["jwks_url"]
304
+ issuer = issuer or template.get("expected_issuer")
305
+ elif jwks_url:
244
306
  payload["jwks_url"] = jwks_url
245
- issuer = string_value(args.issuer)
246
- audience = string_value(args.audience)
247
- if issuer:
248
- payload["expected_issuer"] = issuer
249
- if audience:
250
- payload["expected_audience"] = audience
251
307
  else:
252
308
  payload["jwks_json"] = read_jwks_json(jwks_json_arg)
253
- issuer = string_value(args.issuer)
254
- audience = string_value(args.audience)
255
- if issuer:
256
- payload["expected_issuer"] = issuer
257
- if audience:
258
- payload["expected_audience"] = audience
309
+
310
+ if issuer:
311
+ payload["expected_issuer"] = issuer
312
+ if audience:
313
+ payload["expected_audience"] = audience
259
314
 
260
315
  api_url, api_key = resolve_api_credentials(args)
261
316
  result = ApiClient(api_url, api_key).register_trusted_jwks(payload)
262
317
  org_id = string_value(result.get("org_id")) or "(unknown)"
263
318
  print(f"Registered trusted JWKS for org {org_id}.")
319
+ if workos_client_id:
320
+ print(f" WorkOS client: {workos_client_id}")
321
+ print(f" JWKS URL: {payload['jwks_url']}")
322
+ print(f" Expected iss: {payload.get('expected_issuer', '(unset)')}")
264
323
  print("This registers a trust relationship only; no JWT was minted or fetched.")
265
324
  return 0
266
325
 
@@ -133,8 +133,21 @@ def resolve_relative_import(node: ast.ImportFrom, source: Path, project_root: Pa
133
133
  def module_path_candidates(base: Path, parts: list[str]) -> set[Path]:
134
134
  if not parts:
135
135
  return set()
136
- path = base.joinpath(*parts)
137
136
  candidates: set[Path] = set()
137
+ # Every INTERMEDIATE package __init__.py along the dotted path must ship
138
+ # too: importing pkg.sub.mod executes pkg/__init__.py and pkg/sub/__init__.py.
139
+ # Resolving only the leaf dropped those when nothing imported the package
140
+ # name bare — in the pod the package degrades to an implicit NAMESPACE
141
+ # package, so the import still "succeeds" while every module-level
142
+ # definition in the dropped __init__ is silently gone (caught in-pod by the
143
+ # spmd-canary bundle-completeness gate, urun-infra run 27303509334).
144
+ prefix = base
145
+ for part in parts[:-1]:
146
+ prefix = prefix / part
147
+ intermediate_init = prefix / "__init__.py"
148
+ if intermediate_init.is_file():
149
+ candidates.add(intermediate_init.resolve())
150
+ path = base.joinpath(*parts)
138
151
  module_file = path.with_suffix(".py")
139
152
  package_init = path / "__init__.py"
140
153
  if module_file.is_file():
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes