Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ yarn-error.log*

# compiled example binaries
with-go/with-go

# compiled example binaries
with-grpc/with-grpc
with-microservices-go/bin/
8 changes: 7 additions & 1 deletion with-agent-delegation/demo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,14 @@ async function getUserToken() {
try {
return await signup();
} catch {}
// _delete_user takes an id, not an email: a phone-only signup has no email,
// so email was never an identifier every account has. Look the id up first.
const { _user: stale } = await adminGql(
`query ($params: GetUserRequest!) { _user(params: $params) { id } }`,
{ params: { email: USER_EMAIL } }
);
await adminGql(`mutation ($params: DeleteUserRequest!) { _delete_user(params: $params) { message } }`, {
params: { email: USER_EMAIL },
params: { id: stale.id },
});
return signup();
}
Expand Down
22 changes: 17 additions & 5 deletions with-agent-permissions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ non-zero if any of them does not hold.

## Turning it on: declare `type agent`

**There is no flag.** Declaring `type agent` in your authorization model *is*
the opt-in:
**There is no enabling flag.** Declaring `type agent` in your authorization
model *is* the opt-in:

```dsl
model
Expand All @@ -90,9 +90,13 @@ deny *every* delegated request: a total authorization outage rather than a
graceful degradation. Auto-detection makes that state unreachable.

Section 8 of the demo shows the other side of that trade: rewrite the model
without `type agent` and the same agent immediately inherits Alice's full
authority again. Deployments that never opt in keep their existing behaviour
byte-for-byte, and that state is counted as
without `type agent` and every delegated check is **denied**, because the
agent half of `perms(agent) ∩ perms(user)` cannot be evaluated and a check
that cannot be evaluated is not a check that passes. Authorizing as the user
alone would hand the agent Alice's full authority — the Confused Deputy this
feature exists to prevent — so it is not the default. Deployments migrating
from 2.3.x can set `--fga-allow-unconstrained-agents` to restore exactly that
old behaviour; either way the state is counted as
`authorizer_fga_delegated_checks_total{outcome="not_enforced"}` so you can alert
on agent traffic arriving unconstrained.

Expand Down Expand Up @@ -169,6 +173,14 @@ agent was never granted it, so the agent cannot — no matter how the question i
phrased, because the decision is made server-side from the token, not from the
conversation. Prompt injection has nothing to work with.

> **Why this example still uses `authorizer mcp` (stdio).** Authorizer now also
> serves MCP over HTTP (`--mcp-enabled`, see `with-mcp`), and that is the
> transport to use for anything new. This example cannot move yet: it drives the
> tools with an RFC 8693 **delegated** token, and the HTTP surface deliberately
> does not accept those — delegated tokens are stateless, so they fail the
> session check the HTTP path requires, and widening it is a separate decision.
> Until that lands, agent-delegation over MCP is a stdio-only story.

### Prove it without a model in the loop

```sh
Expand Down
61 changes: 43 additions & 18 deletions with-agent-permissions/demo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ const DOC_PLAN = `document:q4-plan-${runId}`;
const DOC_PAYROLL = `document:payroll-${runId}`;
const DOC_ROADMAP = `document:roadmap-${runId}`;

// Declaring `type agent` IS the opt-in — there is no flag. The feature is
// meaningless without a model that can express agent grants, and checking
// `agent:x` against a model with no agent type ERRORS in OpenFGA rather than
// returning false, so a flag switched on against an unprepared model would deny
// every delegated request. Auto-detection makes that state unreachable.
// Declaring `type agent` IS the opt-in — there is no enabling flag. The
// feature is meaningless without a model that can express agent grants, and
// checking `agent:x` against a model with no agent type ERRORS in OpenFGA
// rather than returning false, so a flag switched on against an unprepared
// model would deny every delegated request. Auto-detection makes that state
// unreachable.
const MODEL_WITH_AGENT = `model
schema 1.1
type user
Expand All @@ -59,8 +60,8 @@ type document
define can_view: viewer
`;

// The same model WITHOUT the agent type: the "operator has not opted in" state,
// used by the final section.
// The same model WITHOUT the agent type: the "operator has not opted in"
// state, used by the final section.
const MODEL_WITHOUT_AGENT = `model
schema 1.1
type user
Expand Down Expand Up @@ -154,6 +155,21 @@ function expect(label, actual, wanted) {
console.log(` ${ok ? "✓" : "✗"} ${label}${ok ? "" : ` (got ${JSON.stringify(actual)}, want ${JSON.stringify(wanted)})`}`);
}

// Some denials are a rejected request rather than `allowed: false` — a check
// the server refuses to evaluate at all returns an error, and swallowing that
// would let a broken deployment read as a passing one.
async function expectDenied(label, call) {
let outcome;
try {
outcome = `allowed: ${JSON.stringify(await call())}`;
} catch (err) {
console.log(` ✓ ${label} (${err.message})`);
return;
}
failures++;
console.log(` ✗ ${label} (got ${outcome}, want a denial)`);
}

// Since 2.4.0 MFA is on by default, so signup/login enrol nothing but OFFER an
// MFA setup: no access token, and the message "Proceed to mfa setup", until the
// user either enrols a factor or explicitly declines. This demo is about
Expand Down Expand Up @@ -200,8 +216,14 @@ async function getUserToken() {
try {
return await signup();
} catch {}
// _delete_user takes an id, not an email: a phone-only signup has no email,
// so email was never an identifier every account has. Look the id up first.
const { _user: stale } = await adminGql(
`query ($params: GetUserRequest!) { _user(params: $params) { id } }`,
{ params: { email: USER_EMAIL } }
);
await adminGql(`mutation ($params: DeleteUserRequest!) { _delete_user(params: $params) { message } }`, {
params: { email: USER_EMAIL },
params: { id: stale.id },
});
return signup();
}
Expand Down Expand Up @@ -319,20 +341,23 @@ async function main() {
expect("calendar-agent -> q4-plan is now denied", await check(delegated, DOC_PLAN), false);
expect("Alice -> q4-plan still allowed", await check(userToken, DOC_PLAN), true);

console.log(`\n== 8. The opt-in: a model with no \`type agent\` ==`);
console.log(`\n== 8. Not opted in: a model with no \`type agent\` ==`);
// Tuples survive a model rewrite — only the schema changed, so Alice keeps
// her payroll grant and the agent keeps the tuples it still has. The ONLY
// difference is that the model can no longer express an agent subject.
// difference is that the model can no longer express an agent subject, so
// the agent half of the intersection cannot be evaluated at all.
await writeModel(MODEL_WITHOUT_AGENT);
expect(
"payroll — the agent now inherits Alice's FULL authority -> allowed",
await check(delegated, DOC_PAYROLL),
true
await expectDenied(
"payroll — the agent half cannot be evaluated -> the whole check is denied",
() => check(delegated, DOC_PAYROLL)
);
console.log(` This is the documented compatibility path, not a bug: deployments that`);
console.log(` have not opted in keep their existing behaviour byte-for-byte. It is`);
console.log(` counted as authorizer_fga_delegated_checks_total{outcome="not_enforced"}`);
console.log(` so you can alert on agent traffic arriving unconstrained.`);
console.log(` Fail closed: a check that cannot be evaluated is not a check that passes.`);
console.log(` Authorizing as the user alone would hand the agent Alice's full authority,`);
console.log(` which is exactly the Confused Deputy this feature exists to prevent.`);
console.log(` Deployments migrating from 2.3.x can set --fga-allow-unconstrained-agents`);
console.log(` to restore that old behaviour; either way it is counted as`);
console.log(` authorizer_fga_delegated_checks_total{outcome="not_enforced"}, so you can`);
console.log(` alert on agent traffic arriving unconstrained.`);

// Leave the store as we found it for the next run.
await writeModel(MODEL_WITH_AGENT);
Expand Down
5 changes: 4 additions & 1 deletion with-agent-permissions/mcp-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import path from "node:path";
import readline from "node:readline";

const HERE = path.dirname(fileURLToPath(import.meta.url));
const SERVER_DIR = path.resolve(HERE, "../../authorizer");
// Override to build a specific checkout, e.g. a release worktree. Must be the
// same checkout run-server.sh started: `authorizer mcp` is a second process
// against the same database, not a client of the running one.
const SERVER_DIR = process.env.AUTHORIZER_SERVER_DIR ?? path.resolve(HERE, "../../authorizer");
const DB_PATH = path.join(HERE, ".agent-demo.db");
// A DELEGATED TOKEN LIVES 5 MINUTES. `go run` recompiles the whole server on
// every spawn, which can eat most of that window before the first tool call —
Expand Down
4 changes: 3 additions & 1 deletion with-agent-permissions/run-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
set -euo pipefail

DIR="$(cd "$(dirname "$0")" && pwd)"
SERVER_DIR="$DIR/../../authorizer"
# Override to run a specific checkout, e.g. a release worktree:
# SERVER_DIR=/path/to/authorizer@2.4.0-rc.18 ./run-server.sh
SERVER_DIR="${SERVER_DIR:-$DIR/../../authorizer}"

# Override when :8080 is taken, e.g. PORT=8098 ./run-server.sh
# (then run the demos with AUTHORIZER_URL=http://localhost:8098)
Expand Down
11 changes: 3 additions & 8 deletions with-agents-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,12 @@ upstream hop dropped (`invalid_scope`), and delegated tokens live 5 minutes.
## Quickstart

Requires a server built from main (`make dev` in the server repo → :8080)
and the **unreleased** Python SDK from local main, checked out next to this
repo. `authorizer-py` 0.3.0rc3 is on PyPI and does have token exchange and
`skip_mfa_setup`, but not the loopback cookie jar that the MFA offer needs:
the server marks the `mfa_session` cookie `Secure` even over plain http, so
against a local server the released SDK drops it and `skip_mfa_setup` fails
with `invalid session`. Switch to `pip install --pre authorizer-py` once
that fix ships:
and the Authorizer Python SDK (token-exchange support ships in
`authorizer-py>=0.3.0rc4`):

```bash
python3 -m venv .venv
.venv/bin/pip install -e ../../authorizer-python
.venv/bin/pip install -r requirements.txt

export AUTHORIZER_CLIENT_ID=kbyuFDidLLm280LIwVFiazOqjO3ty8KH # make-dev default
export AUTHORIZER_ADMIN_SECRET=admin
Expand Down
26 changes: 18 additions & 8 deletions with-agents-python/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,10 @@
python demo.py --async # same flow on the async client

Requires an Authorizer server built from main (`make dev` in the server
repo) and the UNRELEASED Python SDK from local main:
repo) and the Authorizer Python SDK (token exchange ships in
`authorizer-py>=0.3.0rc4`):

pip install -e ../../authorizer-python

(The released authorizer-py 0.3.0rc3 has token exchange and skip_mfa_setup,
but not the loopback cookie jar the MFA offer needs against a local http
server. Switch to `pip install --pre authorizer-py` once that ships.)
pip install -r requirements.txt
"""

from __future__ import annotations
Expand Down Expand Up @@ -85,6 +82,17 @@ def claims(jwt: str) -> dict:
"""


def mfa_cookie(client) -> dict[str, str]:
"""Header replaying the MFA session cookie signup set on this client.

skip_mfa_setup is identified by that cookie plus the email. The server
marks it Secure (--app-cookie-secure defaults to true), so httpx keeps it
in its jar but refuses to replay it over plain http and it has to be sent
by hand. A deployment on https needs none of this.
"""
return {"Cookie": f"mfa_session={client._http.cookies.get('mfa_session')}"}


def print_act_chain(token: str, label: str) -> None:
c = claims(token)
print(f"\n== {label} ==")
Expand Down Expand Up @@ -115,7 +123,7 @@ def run_sync() -> None:
)
)
if user.access_token is None: # MFA setup offered — see MFA_OFFER_NOTE
client.skip_mfa_setup(SkipMfaSetupRequest(email=email))
client.skip_mfa_setup(SkipMfaSetupRequest(email=email), mfa_cookie(client))
user = client.login(
LoginRequest(email=email, password=PASSWORD, scope=USER_SCOPE)
)
Expand Down Expand Up @@ -205,7 +213,9 @@ async def run_async() -> None:
)
)
if user.access_token is None: # MFA setup offered — see MFA_OFFER_NOTE
await client.skip_mfa_setup(SkipMfaSetupRequest(email=email))
await client.skip_mfa_setup(
SkipMfaSetupRequest(email=email), mfa_cookie(client)
)
user = await client.login(
LoginRequest(email=email, password=PASSWORD, scope=scope)
)
Expand Down
3 changes: 3 additions & 0 deletions with-agents-python/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Official Authorizer Python SDK: https://pypi.org/project/authorizer-py/
# Token-exchange (RFC 8693) support ships in >=0.3.0rc4.
authorizer-py>=0.3.0rc4
25 changes: 23 additions & 2 deletions with-auth-recipes/2-totp-mfa/totp-mfa.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ const password = 'Obviously-Fake-Passw0rd!';
const totpFor = (secret) =>
new OTPAuth.TOTP({ secret, algorithm: 'SHA1', digits: 6, period: 30 });

// RFC 6238 §5.2: a code the server has already accepted must not be accepted
// a second time, and Authorizer enforces that. Enrollment and the login
// challenge below happen seconds apart, well inside one 30s step, so the
// second one has to wait for the counter to roll over — a real user typing
// from their phone hits the same rule if they reuse a code.
async function freshCode(secret, alreadyUsed) {
const totp = totpFor(secret);
for (;;) {
const code = totp.generate();
if (code !== alreadyUsed) return code;
await new Promise((r) => setTimeout(r, 1000));
}
}

const AUTH_RESPONSE = `
message
access_token
Expand Down Expand Up @@ -65,11 +79,12 @@ console.log('recovery codes:', enroll.authenticator_recovery_codes.length);

// 3. Complete enrollment: generate the current code and verify it.
// verify_otp requires the mfa_session cookie set in the previous step.
const enrollmentCode = totpFor(enroll.authenticator_secret).generate();
const { data: enrolled, setCookies: sessionCookies } = await gql(
`mutation ($params: VerifyOTPRequest!) {
verify_otp(params: $params) { ${AUTH_RESPONSE} }
}`,
{ params: { email, otp: totpFor(enroll.authenticator_secret).generate(), is_totp: true } },
{ params: { email, otp: enrollmentCode, is_totp: true } },
{ Cookie: cookieHeader(mfaCookies1) }
);
console.log('verify_otp (enrollment):', enrolled.verify_otp.message);
Expand All @@ -88,7 +103,13 @@ const { data: mfaDone } = await gql(
`mutation ($params: VerifyOTPRequest!) {
verify_otp(params: $params) { ${AUTH_RESPONSE} }
}`,
{ params: { email, otp: totpFor(enroll.authenticator_secret).generate(), is_totp: true } },
{
params: {
email,
otp: await freshCode(enroll.authenticator_secret, enrollmentCode),
is_totp: true,
},
},
{ Cookie: cookieHeader(mfaCookies2) }
);
const session = mfaDone.verify_otp;
Expand Down
4 changes: 3 additions & 1 deletion with-auth-recipes/run-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
set -euo pipefail

DIR="$(cd "$(dirname "$0")" && pwd)"
SERVER_DIR="$DIR/../../authorizer"
# Override to run a specific checkout, e.g. a release worktree:
# SERVER_DIR=/path/to/authorizer@2.4.0-rc.18 ./run-server.sh
SERVER_DIR="${SERVER_DIR:-$DIR/../../authorizer}"

# Override when :8080 is taken, e.g. PORT=8098 ./run-server.sh
# (recipe scripts then need AUTHORIZER_URL=http://localhost:8098)
Expand Down
14 changes: 7 additions & 7 deletions with-express-js/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion with-express-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"author": "Lakhan Samani",
"license": "ISC",
"dependencies": {
"@authorizerdev/authorizer-js": "^3.3.0",
"@authorizerdev/authorizer-js": "^4.0.0-rc.0",
"express": "^4.18.2"
}
}
8 changes: 7 additions & 1 deletion with-fga-advanced/api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,14 @@ export async function loginOrSignup(name) {
try {
return await signup();
} catch {}
// _delete_user takes an id, not an email: a phone-only signup has no email,
// so email was never an identifier every account has. Look the id up first.
const { _user: stale } = await adminGql(
`query ($p: GetUserRequest!) { _user(params: $p) { id } }`,
{ p: { email } }
);
await adminGql(`mutation ($p: DeleteUserRequest!) { _delete_user(params: $p) { message } }`, {
p: { email },
p: { id: stale.id },
});
return signup();
}
Expand Down
Loading