Manually authorize requests

Reproduce the access checks that the messaging sidecar runs for you
View as Markdown

When you deploy with the default web or slack adapter, the messaging sidecar handles authorization automatically: every incoming request is checked against the deployment’s grants table before it reaches your agent. If you’re running a frontend agent or a custom HTTP server inside the messaging container, there’s no sidecar to do this for you, but the platform still issues the same credentials, and you can call the same endpoint yourself.

This guide explains the contract and shows how to replicate it.


What the sidecar is doing

For every inbound request, the messaging sidecar:

  1. Extracts an identity from the request (the OIDC user_id for web, the Slack user/team for slack).
  2. Calls GET /api/v1/deployments/authorize on astro-server with that identity and the adapter name, authenticated by the deploy token.
  3. Allows the request if the server returns allowed: true; denies it otherwise.
  4. Caches the answer for ~60s so chatty sessions don’t pay the round-trip on every event.
  5. Falls back to the deploy token’s anyone_adapters claim if the server is unreachable, so an outage doesn’t take down open-grant deployments.

When you handle requests directly, you reproduce these five steps inside your agent.


Where the identity comes from

A frontend agent sits behind the platform’s OIDC layer. After a user signs in, every request that reaches your container carries the signed-in user’s platform ID in a request header:

HeaderValue
x-amzn-oidc-identityThe platform user ID of the signed-in user. This is exactly the identity_id you pass to the authorize endpoint with identity_type=user.

This header is injected by the OIDC layer after sign-in and is the only identity input you need for a web frontend. You read it off the incoming request — you do not mint, decode, or validate it yourself. If it’s absent, the request is unauthenticated (treat as anonymous; see the anyone-grant note below).

Inputs the platform gives you

Every deployed agent receives the following environment variable:

VariableDescription
ASTRO_AUTHZ_TOKENShort JWT signed by astro-server. Use as a bearer credential when calling the authorize endpoint.

The token is opaque to you, but two claims inside it are useful:

ClaimMeaning
issastro-server’s base URL, which is the host you call. No separate ASTRO_AUTHZ_URL env var is needed.
subThis deployment’s ID. You don’t pass it explicitly; the server reads it from the token.
anyone_adaptersAdapters with an anyone grant at deploy time. Used only as a degraded-mode fallback.

Decode the token once at startup to read iss. You do not need to validate its signature, because astro-server validates the token again on every authorize call.


Where the web identity comes from

For a web agent, the Application Load Balancer (ALB) that sits in front of your container runs the OIDC login and forwards the result as request headers. Two headers carry the identity.

x-amzn-oidc-identity holds the OIDC subject, which is the platform user id. Pass this value to the authorize call as identity_id, together with identity_type=user. It contains the subject only, with no email or any other profile field.

x-amzn-oidc-data holds a signed JWT with the full set of claims for the authenticated user, including sub, email, and email_verified. The authorize endpoint never returns the email, so this header is the only place to get it or any other profile field. Which claims are present depends on the scopes your identity provider returns. To read the email, decode the payload segment (the middle of the three parts separated by dots) and read the claim:

1function userEmail(req: Request): string {
2 const data = req.headers.get("x-amzn-oidc-data");
3 if (!data) return ""; // local dev, or a request that did not pass through the ALB
4 const claims = JSON.parse(
5 Buffer.from(data.split(".")[1], "base64url").toString("utf8"),
6 );
7 return claims.email_verified ? (claims.email ?? "") : "";
8}

The ALB also injects x-amzn-oidc-accesstoken, the raw access token from the identity provider. Most agents do not need it.

These headers carry tokens that expire quickly and that the ALB refreshes, so read them on every request rather than persisting or caching the decoded identity beyond the request.

Keep the two JWTs distinct. ASTRO_AUTHZ_TOKEN is the deploy token that you present to astro-server, and its sub is the deployment id. x-amzn-oidc-data is the identity token that the ALB presents to you, and its sub is the user id. They are separate tokens with separate subjects, so do not assume that a sub means the same thing in both.

You can read the claims in x-amzn-oidc-data without verifying its signature only because the ALB strips any copy of these headers sent by the client and sets its own. Nothing downstream can forge them as long as every request reaches your container through the ALB. If your container can be reached directly, so that a request can arrive without passing through the ALB, verify the signature before you trust any claim. Fetch the public key from https://public-keys.auth.elb.<region>.amazonaws.com/<kid>, where the region comes from the signer ARN in the JWT header and kid comes from that same header, then verify with the ES256 algorithm.

When you bring up a frontend agent, a temporary diagnostic route that echoes these headers and their decoded claims helps confirm that the ALB is forwarding identity as expected. Remove or gate such a route before production, because it is unauthenticated and exposes token claims.


The authorize call

Substitute the server URL with the iss claim from your ASTRO_AUTHZ_TOKEN, and pass the raw token as a Bearer credential:

GET
/api/v1/deployments/authorize
1curl -G https://astropods.com/api/v1/deployments/authorize \
2 -H "Authorization: Bearer <token>" \
3 -H "Content-Type: application/json" \
4 -d adapter=slack \
5 -d identity_type=slack \
6 -d identity_id=U12345678 \
7 -d identity_scope=T87654321

Query parameters:

For a web frontend you only set adapter=web and the identity from the x-amzn-oidc-identity header:

ParamWhen to set
adapterweb for a frontend agent. Required.
identity_typeuser for a signed-in user. Leave empty for anonymous (only valid when an anyone grant exists).
identity_idThe value of the x-amzn-oidc-identity header. Must be supplied together with identity_type — supplying one without the other is a 400.

The remaining two parameters exist only for custom adapters that map a non-web identity (e.g. Slack). Ignore them for a web frontend:

ParamWhen to set
adapterslack when mapping a Slack identity. Any value other than web/slack is a 400.
identity_typeuser for a signed-in user, slack for a Slack user, empty for anonymous (only valid when an anyone grant exists).
identity_idThe corresponding user id. Must be supplied together with identity_type. Supplying one without the other is a 400.
identity_scopeSlack only: the team_id (Slack user IDs are only unique per team). Omit for web.

Response (200):

1{
2 "allowed": true,
3 "user_id": "user_01HXY..."
4}
  • allowed is the final decision.
  • user_id is the resolved platform user id when the server could resolve one (for identity_type=user it’s the input echoed back; for slack it’s the linked platform user, empty when no mapping exists). Only present when allowed: true.

Treat 4xx as malformed input (don’t retry) and 5xx as transient (retry once, then fail-closed).


Reference implementation

This is the whole loop for a web frontend: an authorize client, plus a guard that reads x-amzn-oidc-identity off the request, calls the client, and allows or denies. Drop the guard in front of every protected route.

authz.ts
1import type { Request, Response, NextFunction } from "express";
2
3type AuthzResult = { allowed: boolean; userId?: string };
4
5const token = process.env.ASTRO_AUTHZ_TOKEN ?? "";
6const issuer = token
7 ? JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()).iss
8 : "";
9
10async function authorize(identityId: string): Promise<AuthzResult> {
11 if (!token || !issuer) return { allowed: true }; // dev fallback — no platform token locally
12 const url = new URL(`${issuer}/api/v1/deployments/authorize`);
13 url.searchParams.set("adapter", "web");
14 if (identityId) {
15 url.searchParams.set("identity_type", "user");
16 url.searchParams.set("identity_id", identityId);
17 } // else: anonymous — only allowed if the deployment has an `anyone` grant
18
19 const res = await fetch(url, {
20 headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
21 signal: AbortSignal.timeout(5000),
22 });
23 if (!res.ok) throw new Error(`authz: ${res.status}`);
24 const body = (await res.json()) as { allowed: boolean; user_id?: string };
25 return { allowed: body.allowed, userId: body.user_id };
26}
27
28// Express guard: extract the platform identity header, then authorize.
29export async function requireAuthz(req: Request, res: Response, next: NextFunction) {
30 const identityId = req.header("x-amzn-oidc-identity") ?? "";
31 try {
32 const { allowed, userId } = await authorize(identityId);
33 if (!allowed) return res.status(403).send("forbidden");
34 res.locals.userId = userId; // forward downstream (telemetry, etc.)
35 next();
36 } catch {
37 res.status(503).send("authz unavailable"); // fail closed
38 }
39}

If you author a custom adapter instead of a web frontend, the identity won’t be in x-amzn-oidc-identity — source it from your adapter’s transport (e.g. a Slack event payload) and pass adapter/identity_scope accordingly. Everything else about the call is identical.


Caveats worth knowing

  • Cache for ~60s. Without a cache, every page navigation pays a round-trip to astro-server. The sidecar caches per (identity_type, identity_id, adapter, identity_scope) for 60 seconds; do the same. Grant edits take up to a minute to propagate, which is acceptable.
  • Fail-closed on timeouts. A 5s timeout is the platform default. Treat any error as a denial unless the adapter is in the token’s anyone_adapters claim. In that case a server outage should not lock everyone out of an open deployment. Cap the degraded-mode TTL low (10s or so) so recovery is reflected promptly.
  • Anonymous is only valid with an anyone grant. Sending empty identity_type + empty identity_id is allowed by the server only when the adapter is publicly granted. If your UI has a public route, route it through the same authorize call with empty identity instead of branching around it.
  • Use the resolved user_id downstream. For Slack, the linked platform user id is what trace attribution and observability buckets are keyed on. For web, it’s just the input echoed back. Either way, forward result.user_id to your downstream telemetry rather than the raw input.
  • No token, no platform. In local dev (ast project start), ASTRO_AUTHZ_TOKEN is not set and the ALB identity headers (x-amzn-oidc-identity and x-amzn-oidc-data) are absent. Return allowed: true so devs aren’t blocked. The platform only injects the token and the identity headers in deployed builds.

When you don’t need this

If you’re using the default --adapter web flow, the sidecar already handles all of this for you and your agent code never sees ASTRO_AUTHZ_TOKEN directly. This guide is only relevant when:

  • You set agent.interfaces.frontend: true and serve your own UI.
  • You’re authoring a custom adapter and want to wire authorize calls into the request path yourself.
  • You’re building a server-to-server integration that talks to astro-server on the deployment’s behalf.