From 98288fd70269009d46202f066bead152f0014117 Mon Sep 17 00:00:00 2001
From: Lakhan Samani
Date: Tue, 4 Aug 2026 15:13:59 +0530
Subject: [PATCH 01/10] fix(go): migrate to SDK v2 and handle the 2.4.0 MFA
offer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both Go examples were broken against a current server and SDK.
with-microservices-go did not build at all: it carried a `replace` to a
local ../../authorizer-go checkout, so it only ever worked on a
maintainer's machine, and its go.sum was incomplete.
with-go built, but only because it pinned a pre-v2 June pseudo-version —
it never exercised the released SDK. authorizer-go is now the /v2 module
path.
Both now require github.com/authorizerdev/authorizer-go/v2.
Separately, with-go panicked at runtime. Since 2.4.0 MFA is on by
default, so signup/login withhold the access token and return "Proceed
to mfa setup"; dereferencing login.ExpiresIn nil-panicked. The example
now detects the offer and declines it via SkipMfaSetup, which is the
real default-install flow.
Requires the cookie-jar fix in authorizerdev/authorizer-go#27 — the
skip call is identified by a session cookie the SDK previously dropped.
---
.gitignore | 3 ++
with-go/go.mod | 3 +-
with-go/go.sum | 4 +--
with-go/main.go | 31 ++++++++++++++++++-
with-microservices-go/go.mod | 8 ++---
with-microservices-go/go.sum | 4 +++
.../internal/authx/token_source.go | 2 +-
7 files changed, 44 insertions(+), 11 deletions(-)
diff --git a/.gitignore b/.gitignore
index c8e3d5e..1d544be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,6 @@ yarn-debug.log*
yarn-error.log*
.cache
+
+# compiled example binaries
+with-go/with-go
diff --git a/with-go/go.mod b/with-go/go.mod
index 37ede96..45e3077 100644
--- a/with-go/go.mod
+++ b/with-go/go.mod
@@ -2,10 +2,11 @@ module github.com/authorizerdev/examples/with-go
go 1.25.5
-require github.com/authorizerdev/authorizer-go v0.0.0-20260616165143-dc16e71b66f7
+require github.com/authorizerdev/authorizer-go/v2 v2.2.0-rc.4
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect
+ github.com/authorizerdev/authorizer-proto-go v0.2.0-rc.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
diff --git a/with-go/go.sum b/with-go/go.sum
index 20d861f..88c970c 100644
--- a/with-go/go.sum
+++ b/with-go/go.sum
@@ -1,7 +1,7 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
-github.com/authorizerdev/authorizer-go v0.0.0-20260616165143-dc16e71b66f7 h1:JWSpSX7Vz3WczigC1brqR86Dxm43CJNT7XP79xB1fJk=
-github.com/authorizerdev/authorizer-go v0.0.0-20260616165143-dc16e71b66f7/go.mod h1:Ao/GjPMrTqctfhzC/fv+RdjpQ7/LvpNBKEmuhbQO0RU=
+github.com/authorizerdev/authorizer-proto-go v0.2.0-rc.0 h1:PQGjo4yfxU4V4NXOJj1OjbLKNxRpf3ih2eCdTMmUEkY=
+github.com/authorizerdev/authorizer-proto-go v0.2.0-rc.0/go.mod h1:cVUPv4XVeH3YeoFjfnl+ug/KlUinrGOAUZu3E+sjjHs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
diff --git a/with-go/main.go b/with-go/main.go
index 1a053a7..dd76c6e 100644
--- a/with-go/main.go
+++ b/with-go/main.go
@@ -15,7 +15,7 @@ import (
"os"
"time"
- authorizer "github.com/authorizerdev/authorizer-go"
+ authorizer "github.com/authorizerdev/authorizer-go/v2"
)
func env(key, fallback string) string {
@@ -59,6 +59,27 @@ func main() {
if err != nil {
log.Fatal("login: ", err)
}
+
+ // Since 2.4.0 MFA is ON by default, so a brand-new user is OFFERED an MFA
+ // setup and the access token is WITHHELD until they either enrol a factor
+ // or explicitly decline. Login therefore returns no token here — it
+ // returns "Proceed to mfa setup" — and dereferencing the token straight
+ // away panics.
+ //
+ // This example declines, which is what SkipMfaSetup is for: it records the
+ // refusal and releases the withheld token. Identification is by the MFA
+ // session cookie set above plus the email, so it must run on the same
+ // client. Fails if the instance runs with --enforce-mfa, where declining
+ // is not permitted; a real app would drive the TOTP/OTP setup screen
+ // instead.
+ if login.AccessToken == nil {
+ fmt.Println("mfa setup offered:", refString(login.Message))
+ login, err = client.SkipMfaSetup(&authorizer.SkipMfaSetupRequest{Email: &email})
+ if err != nil {
+ log.Fatal("skip mfa setup: ", err)
+ }
+ fmt.Println("mfa setup declined, token issued")
+ }
fmt.Println("logged in, token expires in:", *login.ExpiresIn, "seconds")
// Profile: authenticated with the user's own bearer token.
@@ -84,3 +105,11 @@ func main() {
fmt.Println(" -", u.GetEmail())
}
}
+
+// refString safely reads an optional string field.
+func refString(s *string) string {
+ if s == nil {
+ return ""
+ }
+ return *s
+}
diff --git a/with-microservices-go/go.mod b/with-microservices-go/go.mod
index d2fb3dc..6d1f48a 100644
--- a/with-microservices-go/go.mod
+++ b/with-microservices-go/go.mod
@@ -2,18 +2,14 @@ module github.com/authorizerdev/examples/with-microservices-go
go 1.25.5
-// Local main of the Go SDK: it carries the working client_credentials support
-// in GetToken (GrantTypeClientCredentials). Drop this replace once the next
-// authorizer-go release ships.
-replace github.com/authorizerdev/authorizer-go => ../../authorizer-go
-
require (
- github.com/authorizerdev/authorizer-go v0.0.0-00010101000000-000000000000
+ github.com/authorizerdev/authorizer-go/v2 v2.2.0-rc.4
github.com/golang-jwt/jwt/v5 v5.2.2
)
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect
+ github.com/authorizerdev/authorizer-proto-go v0.1.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
diff --git a/with-microservices-go/go.sum b/with-microservices-go/go.sum
index 69bf807..a93294a 100644
--- a/with-microservices-go/go.sum
+++ b/with-microservices-go/go.sum
@@ -1,5 +1,9 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
+github.com/authorizerdev/authorizer-go/v2 v2.2.0-rc.4 h1:w8qQmAdP9OFiejsPuSsQqCRdWT29f7gHHFf1UTc8KGU=
+github.com/authorizerdev/authorizer-go/v2 v2.2.0-rc.4/go.mod h1:1gnCE9aCctLn9TZRdyjkbyJIQnFJtK2nXkXoGvj40uQ=
+github.com/authorizerdev/authorizer-proto-go v0.1.0 h1:oLGE2OuwCnE6Yr1tRt3fL0zh7L/HpPQfoeS4pgxszlQ=
+github.com/authorizerdev/authorizer-proto-go v0.1.0/go.mod h1:cVUPv4XVeH3YeoFjfnl+ug/KlUinrGOAUZu3E+sjjHs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
diff --git a/with-microservices-go/internal/authx/token_source.go b/with-microservices-go/internal/authx/token_source.go
index 5ad140c..17240a6 100644
--- a/with-microservices-go/internal/authx/token_source.go
+++ b/with-microservices-go/internal/authx/token_source.go
@@ -5,7 +5,7 @@ import (
"sync"
"time"
- authorizer "github.com/authorizerdev/authorizer-go"
+ authorizer "github.com/authorizerdev/authorizer-go/v2"
)
// refreshMargin is how long before expiry a cached token is considered stale.
From d5e647fe6a6b4ff4dbd4d4318b0fa70f4b57ecde Mon Sep 17 00:00:00 2001
From: Lakhan Samani
Date: Tue, 4 Aug 2026 15:49:16 +0530
Subject: [PATCH 02/10] fix(examples): handle the 2.4.0 MFA offer and refresh
SDK pins
Since 2.4.0 MFA is on by default, so signup/login enrol nothing but OFFER
an MFA setup: no access token, message "Proceed to mfa setup". Every
example that assumed a token comes back broke on the first deref.
with-token-exchange-delegation, with-fga-permissions and with-python now
detect the offer and decline it via skip_mfa_setup, which is the real
default-install flow. The skip is identified by an MFA session cookie the
server marks Secure, so none of the three HTTP clients replay it over
plain http and all three carry it by hand.
In with-token-exchange-delegation the skip alone is not enough: the token
it releases carries the default scope, not the scope signup asked for, so
the delegation demo lost the calendar scopes it exists to attenuate. It
now logs in again once the offer is out of the way.
with-python needed two workarounds, both SDK-side rather than
example-side. authorizer-py 0.2.0 has no typed skip_mfa_setup, and its
paginated admin queries still send $data: PaginatedRequest, which the
server renamed to ListUsersRequest, so AuthorizerAdminClient.users()
fails outright. Both are noted in the README.
Separately, with-fga-permissions could not be run twice: writing a tuple
that already exists is an error, not a no-op, and the demo's
signup-or-login fallback shows re-runs were meant to work.
Also refresh stale SDK pins: authorizer-js ^3.2.1 -> ^3.3.0 and
authorizer-react ^2.0.7 -> ^2.2.0-rc.6. The react bump is the one that
mattered; a caret range never resolves a prerelease, so those examples
were pinned to a released 2.0.7 and never saw 2.2.0-rc.6.
---
with-fga-permissions/demo.mjs | 66 +++++++++++++++-----
with-gatsbyjs/package.json | 2 +-
with-nextjs-13/package-lock.json | 33 +++-------
with-nextjs-13/package.json | 4 +-
with-nextjs/package-lock.json | 33 +++-------
with-nextjs/package.json | 4 +-
with-python/README.md | 11 +++-
with-python/main.py | 69 +++++++++++++++++----
with-react-native-expo/package-lock.json | 8 +--
with-react-native-expo/package.json | 2 +-
with-react/package-lock.json | 16 ++---
with-react/package.json | 2 +-
with-token-exchange-delegation/delegate.mjs | 42 ++++++++++++-
13 files changed, 193 insertions(+), 99 deletions(-)
diff --git a/with-fga-permissions/demo.mjs b/with-fga-permissions/demo.mjs
index 06e6232..6c02bb4 100644
--- a/with-fga-permissions/demo.mjs
+++ b/with-fga-permissions/demo.mjs
@@ -12,13 +12,24 @@
const AUTHORIZER_URL = process.env.AUTHORIZER_URL ?? 'http://localhost:8080';
const ADMIN_SECRET = process.env.ADMIN_SECRET ?? 'admin';
+// A token-withheld MFA offer is identified by a session cookie, and Node's
+// fetch has no cookie jar — so carry the cookie across requests by hand.
+let cookie = '';
+
const gql = async (query, variables, headers = {}) => {
const res = await fetch(`${AUTHORIZER_URL}/graphql`, {
method: 'POST',
// CSRF guard: POST /graphql needs an Origin (or Referer) header.
- headers: { 'Content-Type': 'application/json', Origin: AUTHORIZER_URL, ...headers },
+ headers: {
+ 'Content-Type': 'application/json',
+ Origin: AUTHORIZER_URL,
+ ...(cookie && { Cookie: cookie }),
+ ...headers,
+ },
body: JSON.stringify({ query, variables }),
});
+ const mfa = res.headers.getSetCookie().find((c) => c.startsWith('mfa_session='));
+ if (mfa) cookie = mfa.split(';')[0];
const { data, errors } = await res.json();
if (errors?.length) throw new Error(errors[0].message);
return data;
@@ -28,19 +39,36 @@ const gql = async (query, variables, headers = {}) => {
const signupOrLogin = async (email) => {
const password = 'Fga-demo-pass-1!';
const fields = '{ access_token user { id email } }';
+ let auth;
try {
const d = await gql(
`mutation ($p: SignUpRequest!) { signup(params: $p) ${fields} }`,
{ p: { email, password, confirm_password: password } },
);
- return d.signup;
+ auth = d.signup;
} catch {
const d = await gql(
`mutation ($p: LoginRequest!) { login(params: $p) ${fields} }`,
{ p: { email, password } },
);
- return d.login;
+ auth = d.login;
+ }
+
+ // Since 2.4.0 MFA is ON by default, so signup/login OFFER an MFA setup and
+ // WITHHOLD the access token ("Proceed to mfa setup") until the user either
+ // enrols a factor or explicitly declines. This demo declines, which is what
+ // skip_mfa_setup is for: it records the refusal and releases the withheld
+ // token. Identification is by the MFA session cookie set above plus the
+ // email, so it must run on the same client. Fails under --enforce-mfa, where
+ // declining is not permitted; a real app would drive the setup screen.
+ if (!auth.access_token) {
+ const d = await gql(
+ `mutation ($p: SkipMfaSetupRequest!) { skip_mfa_setup(params: $p) ${fields} }`,
+ { p: { email } },
+ );
+ auth = d.skip_mfa_setup;
}
+ return auth;
};
const alice = await signupOrLogin('fga-alice@example.com');
@@ -50,19 +78,27 @@ console.log('bob :', bob.user.id);
// --- 2. Grant access (admin writes tuples) ----------------------------------
// Subjects are "user:" — the token's `sub` claim.
-await gql(
- `mutation ($p: FgaWriteTuplesInput!) { _fga_write_tuples(params: $p) { message } }`,
- {
- p: {
- tuples: [
- { user: `user:${alice.user.id}`, relation: 'owner', object: 'document:1' },
- { user: `user:${bob.user.id}`, relation: 'viewer', object: 'document:1' },
- ],
+try {
+ await gql(
+ `mutation ($p: FgaWriteTuplesInput!) { _fga_write_tuples(params: $p) { message } }`,
+ {
+ p: {
+ tuples: [
+ { user: `user:${alice.user.id}`, relation: 'owner', object: 'document:1' },
+ { user: `user:${bob.user.id}`, relation: 'viewer', object: 'document:1' },
+ ],
+ },
},
- },
- { 'x-authorizer-admin-secret': ADMIN_SECRET },
-);
-console.log('tuples written: alice owner of document:1, bob viewer of document:1');
+ { 'x-authorizer-admin-secret': ADMIN_SECRET },
+ );
+ console.log('tuples written: alice owner of document:1, bob viewer of document:1');
+} catch (err) {
+ // Writing a tuple that already exists is an error, not a no-op, so a second
+ // run of this demo would fail here. The grant from the first run still
+ // stands, which is all the checks below need.
+ if (!/already exist/i.test(err.message)) throw err;
+ console.log('tuples already present from an earlier run, reusing them');
+}
// --- 3. Check access as each user (their own bearer token) ------------------
const checkQuery = `query ($p: CheckPermissionsInput!) {
diff --git a/with-gatsbyjs/package.json b/with-gatsbyjs/package.json
index f34be14..be4b0b6 100644
--- a/with-gatsbyjs/package.json
+++ b/with-gatsbyjs/package.json
@@ -15,7 +15,7 @@
"clean": "gatsby clean"
},
"dependencies": {
- "@authorizerdev/authorizer-react": "^2.0.7",
+ "@authorizerdev/authorizer-react": "^2.2.0-rc.6",
"@mdx-js/mdx": "^1.6.22",
"@mdx-js/react": "^1.6.22",
"babel-plugin-styled-components": "^2.0.2",
diff --git a/with-nextjs-13/package-lock.json b/with-nextjs-13/package-lock.json
index f719433..0a44985 100644
--- a/with-nextjs-13/package-lock.json
+++ b/with-nextjs-13/package-lock.json
@@ -5,8 +5,8 @@
"packages": {
"": {
"dependencies": {
- "@authorizerdev/authorizer-js": "^3.2.1",
- "@authorizerdev/authorizer-react": "^2.0.7",
+ "@authorizerdev/authorizer-js": "^3.3.0",
+ "@authorizerdev/authorizer-react": "^2.2.0-rc.6",
"next": "^13.0.5",
"react": "18.2.0",
"react-dom": "18.2.0"
@@ -22,9 +22,9 @@
}
},
"node_modules/@authorizerdev/authorizer-js": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.2.1.tgz",
- "integrity": "sha512-Z7Vpdqs3JsosCcjV63rd7w7mj5n9Vh+RnGDR+qamTbbII+cbwttHpw1jP+61P2uNQLWC3jjp50HvL7xccWAnJQ==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.3.0.tgz",
+ "integrity": "sha512-sxVroZPffB9IvmCSEE9ZFXplO6AyCfKsUjazUL/G7Cq4WN1diV4K376hY6UbFzSHcZ1K4UTvD1uzqZI2Pa1bwA==",
"license": "MIT",
"dependencies": {
"cross-fetch": "^4.1.0"
@@ -37,12 +37,12 @@
}
},
"node_modules/@authorizerdev/authorizer-react": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-2.0.7.tgz",
- "integrity": "sha512-+qBrbdE6VyljOge1Ad2AmVIpoheymlLsMxCZHW0hnCeKf0R0wJz3MvoKBiaHAcRF/Bf8Wc6+NBCctAnzXjiwMg==",
+ "version": "2.2.0-rc.6",
+ "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-2.2.0-rc.6.tgz",
+ "integrity": "sha512-gQU92EnsH2L6olR6Vu2ryj24MGxINmROoWtOVLGzHkdj5fAhkGQGJPV7BNdpMnTJdnNeo7oc7K2QNjqDW4zbyg==",
"license": "MIT",
"dependencies": {
- "@authorizerdev/authorizer-js": "3.0.4",
+ "@authorizerdev/authorizer-js": "^3.3.0",
"@storybook/preset-scss": "^1.0.3",
"validator": "^13.11.0"
},
@@ -53,21 +53,6 @@
"react": ">=16"
}
},
- "node_modules/@authorizerdev/authorizer-react/node_modules/@authorizerdev/authorizer-js": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.0.4.tgz",
- "integrity": "sha512-vkXg1inxC6U2eLra/EQmhTVKzdlpCF4+a93tOfUgSyISYnE8v9np54OAOrs//4aTVOwFTIhahSTvpERKj2NZAQ==",
- "license": "MIT",
- "dependencies": {
- "cross-fetch": "^4.1.0"
- },
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "url": "https://github.com/sponsors/authorizerdev"
- }
- },
"node_modules/@authorizerdev/authorizer-react/node_modules/@storybook/preset-scss": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@storybook/preset-scss/-/preset-scss-1.0.3.tgz",
diff --git a/with-nextjs-13/package.json b/with-nextjs-13/package.json
index f6e038c..4039cf4 100644
--- a/with-nextjs-13/package.json
+++ b/with-nextjs-13/package.json
@@ -8,8 +8,8 @@
"turboBuild": "tailwindcss input.css --output output.css && next build"
},
"dependencies": {
- "@authorizerdev/authorizer-js": "^3.2.1",
- "@authorizerdev/authorizer-react": "^2.0.7",
+ "@authorizerdev/authorizer-js": "^3.3.0",
+ "@authorizerdev/authorizer-react": "^2.2.0-rc.6",
"next": "^13.0.5",
"react": "18.2.0",
"react-dom": "18.2.0"
diff --git a/with-nextjs/package-lock.json b/with-nextjs/package-lock.json
index 54adc11..3491df3 100644
--- a/with-nextjs/package-lock.json
+++ b/with-nextjs/package-lock.json
@@ -5,8 +5,8 @@
"packages": {
"": {
"dependencies": {
- "@authorizerdev/authorizer-js": "^3.2.1",
- "@authorizerdev/authorizer-react": "^2.0.7",
+ "@authorizerdev/authorizer-js": "^3.3.0",
+ "@authorizerdev/authorizer-react": "^2.2.0-rc.6",
"next": "^12.3.4",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -31,9 +31,9 @@
}
},
"node_modules/@authorizerdev/authorizer-js": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.2.1.tgz",
- "integrity": "sha512-Z7Vpdqs3JsosCcjV63rd7w7mj5n9Vh+RnGDR+qamTbbII+cbwttHpw1jP+61P2uNQLWC3jjp50HvL7xccWAnJQ==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.3.0.tgz",
+ "integrity": "sha512-sxVroZPffB9IvmCSEE9ZFXplO6AyCfKsUjazUL/G7Cq4WN1diV4K376hY6UbFzSHcZ1K4UTvD1uzqZI2Pa1bwA==",
"license": "MIT",
"dependencies": {
"cross-fetch": "^4.1.0"
@@ -46,12 +46,12 @@
}
},
"node_modules/@authorizerdev/authorizer-react": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-2.0.7.tgz",
- "integrity": "sha512-+qBrbdE6VyljOge1Ad2AmVIpoheymlLsMxCZHW0hnCeKf0R0wJz3MvoKBiaHAcRF/Bf8Wc6+NBCctAnzXjiwMg==",
+ "version": "2.2.0-rc.6",
+ "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-2.2.0-rc.6.tgz",
+ "integrity": "sha512-gQU92EnsH2L6olR6Vu2ryj24MGxINmROoWtOVLGzHkdj5fAhkGQGJPV7BNdpMnTJdnNeo7oc7K2QNjqDW4zbyg==",
"license": "MIT",
"dependencies": {
- "@authorizerdev/authorizer-js": "3.0.4",
+ "@authorizerdev/authorizer-js": "^3.3.0",
"@storybook/preset-scss": "^1.0.3",
"validator": "^13.11.0"
},
@@ -62,21 +62,6 @@
"react": ">=16"
}
},
- "node_modules/@authorizerdev/authorizer-react/node_modules/@authorizerdev/authorizer-js": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.0.4.tgz",
- "integrity": "sha512-vkXg1inxC6U2eLra/EQmhTVKzdlpCF4+a93tOfUgSyISYnE8v9np54OAOrs//4aTVOwFTIhahSTvpERKj2NZAQ==",
- "license": "MIT",
- "dependencies": {
- "cross-fetch": "^4.1.0"
- },
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "url": "https://github.com/sponsors/authorizerdev"
- }
- },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
diff --git a/with-nextjs/package.json b/with-nextjs/package.json
index dae2427..2d67cd9 100644
--- a/with-nextjs/package.json
+++ b/with-nextjs/package.json
@@ -6,8 +6,8 @@
"start": "next start"
},
"dependencies": {
- "@authorizerdev/authorizer-js": "^3.2.1",
- "@authorizerdev/authorizer-react": "^2.0.7",
+ "@authorizerdev/authorizer-js": "^3.3.0",
+ "@authorizerdev/authorizer-react": "^2.2.0-rc.6",
"next": "^12.3.4",
"react": "^18.2.0",
"react-dom": "^18.2.0"
diff --git a/with-python/README.md b/with-python/README.md
index 30dfc8e..fcc2c8b 100644
--- a/with-python/README.md
+++ b/with-python/README.md
@@ -1,6 +1,6 @@
# Authorizer Example with Python
-Signup, login and profile with the [Python SDK](https://github.com/authorizerdev/authorizer-python) (`authorizer-py` 0.2.0) sync client, plus the admin client listing users.
+Signup, login and profile with the [Python SDK](https://github.com/authorizerdev/authorizer-python) (`authorizer-py` 0.2.0) sync client, plus an admin query listing users.
## Run an Authorizer instance
@@ -25,4 +25,11 @@ Defaults match `make dev`; override with `AUTHORIZER_URL`, `CLIENT_ID`, `ADMIN_S
- The pip package is **`authorizer-py`**; the import is `authorizer`.
- The client supports three wire protocols: `graphql` (default), `rest`, and `grpc` (`AuthorizerClient(..., protocol="grpc")`; gRPC needs `pip install 'authorizer-py[grpc]'`).
- Async variants exist for both clients: `AsyncAuthorizerClient`, `AsyncAuthorizerAdminClient`.
-- Admin operations authenticate with the `x-authorizer-admin-secret` header, handled by `AuthorizerAdminClient`.
+- Admin operations authenticate with the `x-authorizer-admin-secret` header, normally via `AuthorizerAdminClient`.
+
+## Known gaps in `authorizer-py` 0.2.0
+
+Two things this example works around, both fixed by an SDK release rather than by the example:
+
+- **No `skip_mfa_setup`.** Since server 2.4.0 MFA is on by default, so signup/login withhold the access token and return `Proceed to mfa setup`; declining the offer is what releases the token. The SDK has no typed call for it, so `main.py` goes through the `graphql_query` escape hatch. The call is identified by the MFA session cookie, which the server marks `Secure` — httpx keeps it in its jar but will not replay it over plain `http`, so the example passes it by hand.
+- **Paginated admin queries are rejected by a 2.4.0 server.** `AuthorizerAdminClient.users()` still sends `$data: PaginatedRequest`, a type the server renamed to `ListUsersRequest`, so it fails with `Unknown type "PaginatedRequest"`. `verification_requests()`, `webhooks()` and `email_templates()` have the same drift. `main.py` issues the `_users` query directly instead.
diff --git a/with-python/main.py b/with-python/main.py
index 112ee66..4370672 100644
--- a/with-python/main.py
+++ b/with-python/main.py
@@ -14,7 +14,6 @@
import time
from authorizer import (
- AuthorizerAdminClient,
AuthorizerClient,
LoginRequest,
SignUpRequest,
@@ -24,6 +23,36 @@
CLIENT_ID = os.environ.get("CLIENT_ID", "kbyuFDidLLm280LIwVFiazOqjO3ty8KH")
ADMIN_SECRET = os.environ.get("ADMIN_SECRET", "admin")
+SKIP_MFA_SETUP = """
+mutation ($p: SkipMfaSetupRequest!) {
+ skip_mfa_setup(params: $p) { access_token expires_in }
+}
+"""
+
+
+def skip_mfa_offer(client: AuthorizerClient, email: str) -> dict:
+ """Decline an MFA setup offer and collect the access token it withheld.
+
+ Since 2.4.0 MFA is on by default, so signup/login enrol nothing but OFFER
+ an MFA setup: they return no access token and the message "Proceed to mfa
+ setup" until the user either enrols a factor or explicitly declines.
+ skip_mfa_setup records the refusal and releases the withheld token. It
+ fails under --enforce-mfa, where declining is not permitted; a real app
+ would drive the TOTP/OTP setup screen instead of calling this.
+
+ Two workarounds live here. authorizer-py 0.2.0 has no typed
+ skip_mfa_setup, so the call goes through the graphql_query escape hatch.
+ And the call is identified by the MFA session cookie set on the
+ signup/login response, which the server marks Secure -- so httpx keeps it
+ in its jar but will not replay it over plain http, and it has to be sent
+ by hand.
+ """
+ session = client._http.cookies.get("mfa_session")
+ data = client.graphql_query(
+ SKIP_MFA_SETUP, {"p": {"email": email}}, {"Cookie": f"mfa_session={session}"}
+ )
+ return data["skip_mfa_setup"]
+
def main() -> None:
# ---- Public client (protocol="graphql" is the default; also: rest, grpc)
@@ -39,22 +68,36 @@ def main() -> None:
# Login (redundant right after signup, shown for completeness).
token = client.login(LoginRequest(email=email, password=password))
- print("logged in, token expires in:", token.expires_in, "seconds")
+ access_token, expires_in = token.access_token, token.expires_in
+ if access_token is None:
+ # MFA setup was offered and the token withheld -- decline it.
+ print("mfa setup offered:", token.message)
+ skipped = skip_mfa_offer(client, email)
+ access_token, expires_in = skipped["access_token"], skipped["expires_in"]
+ print("mfa setup declined, token issued")
+ print("logged in, token expires in:", expires_in, "seconds")
# Profile: authenticated with the user's own bearer token.
- profile = client.get_profile({"Authorization": f"Bearer {token.access_token}"})
+ profile = client.get_profile({"Authorization": f"Bearer {access_token}"})
print("profile:", profile.email, "id:", profile.id)
- client.close()
- # ---- Admin client (authenticates with x-authorizer-admin-secret) ----
- admin = AuthorizerAdminClient(
- authorizer_url=AUTHORIZER_URL, admin_secret=ADMIN_SECRET
- )
- users = admin.users() # default pagination
- print(f"admin: {len(users.users)} user(s) on this instance:")
- for user in users.users:
- print(" -", user.email)
- admin.close()
+ # ---- Admin operations (authenticate with x-authorizer-admin-secret) ----
+ # This SHOULD be AuthorizerAdminClient(...).users(), but that method is
+ # broken against a 2.4.0 server: authorizer-py 0.2.0 still sends
+ # `$data: PaginatedRequest`, and the server renamed that input type to
+ # ListUsersRequest, so the query is rejected with `Unknown type
+ # "PaginatedRequest"`. The same drift affects the SDK's verification_requests,
+ # webhooks and email_templates queries. Until the SDK catches up, issue the
+ # query directly -- graphql_query takes per-call headers, so the admin
+ # secret goes on the request the same way the admin client would send it.
+ users = client.graphql_query(
+ "query { _users { pagination { total } users { email } } }",
+ headers={"x-authorizer-admin-secret": ADMIN_SECRET},
+ )["_users"]
+ print(f"admin: {users['pagination']['total']} user(s) on this instance:")
+ for user in users["users"]:
+ print(" -", user["email"])
+ client.close()
if __name__ == "__main__":
diff --git a/with-react-native-expo/package-lock.json b/with-react-native-expo/package-lock.json
index 44f6249..a66f734 100644
--- a/with-react-native-expo/package-lock.json
+++ b/with-react-native-expo/package-lock.json
@@ -8,7 +8,7 @@
"name": "with-react-native-expo",
"version": "1.0.0",
"dependencies": {
- "@authorizerdev/authorizer-js": "^3.2.1",
+ "@authorizerdev/authorizer-js": "^3.3.0",
"expo": "~49.0.15",
"expo-auth-session": "~5.0.2",
"expo-crypto": "~12.4.1",
@@ -36,9 +36,9 @@
}
},
"node_modules/@authorizerdev/authorizer-js": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.2.1.tgz",
- "integrity": "sha512-Z7Vpdqs3JsosCcjV63rd7w7mj5n9Vh+RnGDR+qamTbbII+cbwttHpw1jP+61P2uNQLWC3jjp50HvL7xccWAnJQ==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.3.0.tgz",
+ "integrity": "sha512-sxVroZPffB9IvmCSEE9ZFXplO6AyCfKsUjazUL/G7Cq4WN1diV4K376hY6UbFzSHcZ1K4UTvD1uzqZI2Pa1bwA==",
"license": "MIT",
"dependencies": {
"cross-fetch": "^4.1.0"
diff --git a/with-react-native-expo/package.json b/with-react-native-expo/package.json
index 6dec378..c2e0df7 100644
--- a/with-react-native-expo/package.json
+++ b/with-react-native-expo/package.json
@@ -9,7 +9,7 @@
"web": "expo start --web"
},
"dependencies": {
- "@authorizerdev/authorizer-js": "^3.2.1",
+ "@authorizerdev/authorizer-js": "^3.3.0",
"expo": "~49.0.15",
"expo-auth-session": "~5.0.2",
"expo-crypto": "~12.4.1",
diff --git a/with-react/package-lock.json b/with-react/package-lock.json
index c13e151..29dce9a 100644
--- a/with-react/package-lock.json
+++ b/with-react/package-lock.json
@@ -8,7 +8,7 @@
"name": "authorizer-demo",
"version": "1.0.0",
"dependencies": {
- "@authorizerdev/authorizer-react": "^2.0.7",
+ "@authorizerdev/authorizer-react": "^2.2.0-rc.6",
"history": "5.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -33,9 +33,9 @@
}
},
"node_modules/@authorizerdev/authorizer-js": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.0.4.tgz",
- "integrity": "sha512-vkXg1inxC6U2eLra/EQmhTVKzdlpCF4+a93tOfUgSyISYnE8v9np54OAOrs//4aTVOwFTIhahSTvpERKj2NZAQ==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.3.0.tgz",
+ "integrity": "sha512-sxVroZPffB9IvmCSEE9ZFXplO6AyCfKsUjazUL/G7Cq4WN1diV4K376hY6UbFzSHcZ1K4UTvD1uzqZI2Pa1bwA==",
"license": "MIT",
"dependencies": {
"cross-fetch": "^4.1.0"
@@ -48,12 +48,12 @@
}
},
"node_modules/@authorizerdev/authorizer-react": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-2.0.7.tgz",
- "integrity": "sha512-+qBrbdE6VyljOge1Ad2AmVIpoheymlLsMxCZHW0hnCeKf0R0wJz3MvoKBiaHAcRF/Bf8Wc6+NBCctAnzXjiwMg==",
+ "version": "2.2.0-rc.6",
+ "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-react/-/authorizer-react-2.2.0-rc.6.tgz",
+ "integrity": "sha512-gQU92EnsH2L6olR6Vu2ryj24MGxINmROoWtOVLGzHkdj5fAhkGQGJPV7BNdpMnTJdnNeo7oc7K2QNjqDW4zbyg==",
"license": "MIT",
"dependencies": {
- "@authorizerdev/authorizer-js": "3.0.4",
+ "@authorizerdev/authorizer-js": "^3.3.0",
"@storybook/preset-scss": "^1.0.3",
"validator": "^13.11.0"
},
diff --git a/with-react/package.json b/with-react/package.json
index 6ff7cf5..5cdec51 100644
--- a/with-react/package.json
+++ b/with-react/package.json
@@ -5,7 +5,7 @@
"keywords": [],
"main": "src/index.js",
"dependencies": {
- "@authorizerdev/authorizer-react": "^2.0.7",
+ "@authorizerdev/authorizer-react": "^2.2.0-rc.6",
"history": "5.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
diff --git a/with-token-exchange-delegation/delegate.mjs b/with-token-exchange-delegation/delegate.mjs
index 9f77114..c683d5e 100644
--- a/with-token-exchange-delegation/delegate.mjs
+++ b/with-token-exchange-delegation/delegate.mjs
@@ -18,12 +18,22 @@ if (!AGENT_CLIENT_ID || !AGENT_CLIENT_SECRET) {
process.exit(1);
}
+// A token-withheld MFA offer is identified by a session cookie, and Node's
+// fetch has no cookie jar — so carry the cookie across requests by hand.
+let cookie = '';
+
const gql = async (query, variables) => {
const res = await fetch(`${AUTHORIZER_URL}/graphql`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json', Origin: AUTHORIZER_URL },
+ headers: {
+ 'Content-Type': 'application/json',
+ Origin: AUTHORIZER_URL,
+ ...(cookie && { Cookie: cookie }),
+ },
body: JSON.stringify({ query, variables }),
});
+ const mfa = res.headers.getSetCookie().find((c) => c.startsWith('mfa_session='));
+ if (mfa) cookie = mfa.split(';')[0];
return res.json();
};
@@ -52,7 +62,35 @@ const auth = await gql(
{ params: { email, password, confirm_password: password, scope } },
);
if (auth.errors?.length) throw new Error(auth.errors[0].message);
-const userToken = auth.data.signup.access_token;
+
+// Since 2.4.0 MFA is ON by default, so signup OFFERS an MFA setup and WITHHOLDS
+// the access token ("Proceed to mfa setup") until the user either enrols a
+// factor or explicitly declines. This demo declines, which is what
+// skip_mfa_setup is for: it records the refusal and releases the withheld
+// token. Identification is by the MFA session cookie set above plus the email,
+// so it must run on the same client. Fails under --enforce-mfa, where declining
+// is not permitted; a real app would drive the TOTP/OTP setup screen instead.
+//
+// The token skip_mfa_setup releases carries the DEFAULT scope, not the scope
+// signup asked for — the pending MFA session does not carry the request's
+// scope through. So log in again once the offer is out of the way: now that
+// the user has declined, login returns a token directly, with the scope we ask
+// for. That scope is the subject authority the exchange below attenuates.
+let userToken = auth.data.signup.access_token;
+if (!userToken) {
+ const skipped = await gql(
+ `mutation ($params: SkipMfaSetupRequest!) { skip_mfa_setup(params: $params) { access_token } }`,
+ { params: { email } },
+ );
+ if (skipped.errors?.length) throw new Error(skipped.errors[0].message);
+
+ const relogin = await gql(
+ `mutation ($params: LoginRequest!) { login(params: $params) { access_token } }`,
+ { params: { email, password, scope } },
+ );
+ if (relogin.errors?.length) throw new Error(relogin.errors[0].message);
+ userToken = relogin.data.login.access_token;
+}
console.log('1. user token scope :', decode(userToken).scope.join(' '));
// --- 2. Agent gets its own token (client_credentials) ---------------------
From 4f369f87bd3450698759351182f2fd6a4110ca66 Mon Sep 17 00:00:00 2001
From: Lakhan Samani
Date: Tue, 4 Aug 2026 15:56:53 +0530
Subject: [PATCH 03/10] fix(examples): unblock python agents and express
server-side calls
with-agents-python hit the same 2.4.0 MFA offer as the other examples:
signup returns no token, so the first claims() deref died. Both the sync
and async flows now decline via skip_mfa_setup and log in again -- the
skip alone releases a default-scoped token, and this demo needs the crm
scopes it exists to attenuate.
Its setup.py printed client.id as the client id. That happens to equal
client_id for admin-created clients, so it worked by accident; the SDK now
exposes client_id, so use it. The README note calling that a parity gap is
stale.
The README also pointed pip at ../../../authorizer-python, one level above
where a sibling checkout actually sits, so the documented install could
never have worked. authorizer-py 0.3.0rc3 is on PyPI now and has both
token exchange and skip_mfa_setup, but not the loopback cookie jar the MFA
offer needs -- the server marks mfa_session Secure even over http, so the
released SDK drops it and skip_mfa_setup fails with "invalid session".
Still local-main-only, for a reason worth writing down.
with-express-js could not validate any token against a 2.4.0 server. The
CSRF guard requires Origin or Referer on state-changing requests and
validateJWTToken is a POST /graphql; a browser sets Origin, Node does not,
so every request 403'd before the token was read. Send it via
extraHeaders. Verified end to end: valid id_token -> 200, garbage -> 403.
---
with-agents-python/README.md | 22 ++++++++----
with-agents-python/demo.py | 55 +++++++++++++++++++++++++-----
with-agents-python/setup.py | 5 ++-
with-express-js/README.md | 11 +++++-
with-express-js/auth_middleware.js | 12 +++++--
with-express-js/package-lock.json | 14 ++++----
with-express-js/package.json | 2 +-
7 files changed, 94 insertions(+), 27 deletions(-)
diff --git a/with-agents-python/README.md b/with-agents-python/README.md
index 05f7dcb..8337419 100644
--- a/with-agents-python/README.md
+++ b/with-agents-python/README.md
@@ -21,13 +21,17 @@ 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 (token-exchange support
-merged, not yet on PyPI — switch to `pip install authorizer-py` at the next
-release):
+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:
```bash
python3 -m venv .venv
-.venv/bin/pip install -e ../../../authorizer-python
+.venv/bin/pip install -e ../../authorizer-python
export AUTHORIZER_CLIENT_ID=kbyuFDidLLm280LIwVFiazOqjO3ty8KH # make-dev default
export AUTHORIZER_ADMIN_SECRET=admin
@@ -56,6 +60,10 @@ export AUTHORIZER_ADMIN_SECRET=admin
- `GetTokenRequest` carries all RFC 8693 params (`subject_token`,
`actor_token`, `resource`, plus `client_secret` for the exchange auth)
- The async client (`AsyncAuthorizerClient`) mirrors the sync API 1:1
-- Known parity gap: the SDK's `Client` type doesn't expose `client_id` yet
- (server added it in authorizer#664); `setup.py` uses `client.id`, which
- equals `client_id` for admin-created clients
+- The SDK's `Client` type now exposes `client_id` (the public OAuth
+ identifier) alongside `id` (the internal surrogate key), so `setup.py`
+ prints `client.client_id`. The two coincide for admin-created clients but
+ not in general — the reserved interactive client is one where they differ
+- Signup returns no access token on a default install: MFA is on since
+ 2.4.0, so both flows decline the offer with `skip_mfa_setup` and then log
+ in again to get a token carrying the demo's `crm:*` scopes
diff --git a/with-agents-python/demo.py b/with-agents-python/demo.py
index 164e02c..e004c0b 100644
--- a/with-agents-python/demo.py
+++ b/with-agents-python/demo.py
@@ -11,9 +11,11 @@
Requires an Authorizer server built from main (`make dev` in the server
repo) and the UNRELEASED Python SDK from local main:
- pip install -e ../../../authorizer-python
+ pip install -e ../../authorizer-python
-(Advice: switch to `pip install authorizer-py` once the next release ships.)
+(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.)
"""
from __future__ import annotations
@@ -33,7 +35,9 @@
AsyncAuthorizerClient,
AuthorizerClient,
GetTokenRequest,
+ LoginRequest,
SignUpRequest,
+ SkipMfaSetupRequest,
)
from authorizer import AuthorizerError
@@ -57,6 +61,30 @@ def claims(jwt: str) -> dict:
return json.loads(base64.urlsafe_b64decode(payload))
+PASSWORD = "Agents-demo-1!"
+
+# The user's own rights. Every delegated token below is carved out of these:
+# an agent can only ever narrow what the user already holds.
+USER_SCOPE = ["openid", "email", "crm:read", "crm:write", "report:write"]
+
+MFA_OFFER_NOTE = """
+ Since 2.4.0 MFA is on by default, so signup enrols nothing but OFFERS an
+ MFA setup: it returns no access token and the message "Proceed to mfa
+ setup" until the user either enrols a factor or explicitly declines.
+ This demo declines, which is what skip_mfa_setup is for.
+
+ Declining is not quite enough here. The token skip_mfa_setup releases
+ carries the DEFAULT scope, not the scope signup asked for -- the pending
+ MFA session does not carry the request's scope through -- and this demo
+ needs the crm/report scopes it exists to attenuate. So log in again once
+ the offer is out of the way: the user has now declined, so login returns
+ a token directly, with the scope we ask for.
+
+ Under --enforce-mfa declining is not permitted and skip_mfa_setup fails;
+ a real app would drive the TOTP/OTP setup screen instead.
+"""
+
+
def print_act_chain(token: str, label: str) -> None:
c = claims(token)
print(f"\n== {label} ==")
@@ -81,11 +109,16 @@ def run_sync() -> None:
user = client.signup(
SignUpRequest(
email=email,
- password="Agents-demo-1!",
- confirm_password="Agents-demo-1!",
- scope=["openid", "email", "crm:read", "crm:write", "report:write"],
+ password=PASSWORD,
+ confirm_password=PASSWORD,
+ scope=USER_SCOPE,
)
)
+ if user.access_token is None: # MFA setup offered — see MFA_OFFER_NOTE
+ client.skip_mfa_setup(SkipMfaSetupRequest(email=email))
+ user = client.login(
+ LoginRequest(email=email, password=PASSWORD, scope=USER_SCOPE)
+ )
print(f"1. user token minted for {email}")
print(f" scope: {claims(user.access_token).get('scope')}")
@@ -162,14 +195,20 @@ async def run_async() -> None:
die("run setup.py first and export the variables it prints")
async with AsyncAuthorizerClient(client_id=CLIENT_ID, authorizer_url=AUTHORIZER_URL) as client:
email = f"agents-demo-async+{int(time.time())}@example.com"
+ scope = ["openid", "crm:read"]
user = await client.signup(
SignUpRequest(
email=email,
- password="Agents-demo-1!",
- confirm_password="Agents-demo-1!",
- scope=["openid", "crm:read"],
+ password=PASSWORD,
+ confirm_password=PASSWORD,
+ scope=scope,
)
)
+ if user.access_token is None: # MFA setup offered — see MFA_OFFER_NOTE
+ await client.skip_mfa_setup(SkipMfaSetupRequest(email=email))
+ user = await client.login(
+ LoginRequest(email=email, password=PASSWORD, scope=scope)
+ )
async with AsyncAuthorizerClient(client_id=ORCHESTRATOR_ID, authorizer_url=AUTHORIZER_URL) as orch_client:
orch = await orch_client.get_token(
GetTokenRequest(grant_type=GRANT_TYPE_CLIENT_CREDENTIALS, client_secret=ORCHESTRATOR_SECRET)
diff --git a/with-agents-python/setup.py b/with-agents-python/setup.py
index a288115..987c84b 100644
--- a/with-agents-python/setup.py
+++ b/with-agents-python/setup.py
@@ -35,5 +35,8 @@
description="with-agents-python demo agent (safe to delete)",
)
)
- print(f"export {prefix}_CLIENT_ID={res.client.id}")
+ # client_id is the public OAuth identifier the token endpoint expects; id
+ # is the internal surrogate key. They coincide for admin-created clients,
+ # but not in general, so use the one that is actually being asked for.
+ print(f"export {prefix}_CLIENT_ID={res.client.client_id}")
print(f"export {prefix}_CLIENT_SECRET={res.client_secret}")
diff --git a/with-express-js/README.md b/with-express-js/README.md
index 4cad315..efc7393 100644
--- a/with-express-js/README.md
+++ b/with-express-js/README.md
@@ -7,13 +7,22 @@ Express middleware that validates Authorizer JWTs using [`@authorizerdev/authori
Update the constructor in `auth_middleware.js` with your instance details:
```js
+const authorizerURL = 'https://your-instance.example.com'; // Base URL of your Authorizer instance
+
const authRef = new Authorizer({
- authorizerURL: 'https://your-instance.example.com', // Base URL of your Authorizer instance
+ authorizerURL,
redirectURL: 'https://your-app.example.com', // URL to redirect to after login
clientID: 'YOUR_CLIENT_ID', // Client ID from the Authorizer dashboard
+ extraHeaders: { Origin: authorizerURL }, // required server-side, see below
});
```
+`extraHeaders` is not optional here. The server's CSRF guard rejects any
+state-changing request that arrives without an `Origin` (or `Referer`) header,
+and `validateJWTToken` is a `POST /graphql`. Browsers set `Origin` themselves;
+Node does not, so a server-side caller has to send it or every validation
+fails with a `403` before the token is ever looked at.
+
> Authorizer v2 server is configured entirely via CLI flags (no `.env` / OS env vars), e.g.
> `./authorizer --database-type sqlite --database-url authorizer.db --admin-secret `
diff --git a/with-express-js/auth_middleware.js b/with-express-js/auth_middleware.js
index 196da15..0e21903 100644
--- a/with-express-js/auth_middleware.js
+++ b/with-express-js/auth_middleware.js
@@ -1,9 +1,17 @@
const { Authorizer } = require('@authorizerdev/authorizer-js');
+const authorizerURL = 'https://demo.authorizer.dev';
+
const authRef = new Authorizer({
- authorizerURL: 'https://demo.authorizer.dev',
- redirectURL: 'https://demo.authorizer.dev/app',
+ authorizerURL,
+ redirectURL: `${authorizerURL}/app`,
clientID: '96fed66c-9779-4694-a79a-260fc489ce33',
+ // The server's CSRF guard rejects any state-changing request without an
+ // Origin (or Referer) header, and POST /graphql — which the SDK uses for
+ // validateJWTToken — is state-changing. A browser sets Origin itself, but
+ // this middleware runs in Node, where nothing does, so send it explicitly.
+ // The server's own origin always passes.
+ extraHeaders: { Origin: authorizerURL },
});
const authMiddleware = async (req, res, next) => {
diff --git a/with-express-js/package-lock.json b/with-express-js/package-lock.json
index 61be9b1..aa193c0 100644
--- a/with-express-js/package-lock.json
+++ b/with-express-js/package-lock.json
@@ -9,14 +9,14 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
- "@authorizerdev/authorizer-js": "^3.2.1",
+ "@authorizerdev/authorizer-js": "^3.3.0",
"express": "^4.18.2"
}
},
"node_modules/@authorizerdev/authorizer-js": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.2.1.tgz",
- "integrity": "sha512-Z7Vpdqs3JsosCcjV63rd7w7mj5n9Vh+RnGDR+qamTbbII+cbwttHpw1jP+61P2uNQLWC3jjp50HvL7xccWAnJQ==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.3.0.tgz",
+ "integrity": "sha512-sxVroZPffB9IvmCSEE9ZFXplO6AyCfKsUjazUL/G7Cq4WN1diV4K376hY6UbFzSHcZ1K4UTvD1uzqZI2Pa1bwA==",
"license": "MIT",
"dependencies": {
"cross-fetch": "^4.1.0"
@@ -658,9 +658,9 @@
},
"dependencies": {
"@authorizerdev/authorizer-js": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.2.1.tgz",
- "integrity": "sha512-Z7Vpdqs3JsosCcjV63rd7w7mj5n9Vh+RnGDR+qamTbbII+cbwttHpw1jP+61P2uNQLWC3jjp50HvL7xccWAnJQ==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.3.0.tgz",
+ "integrity": "sha512-sxVroZPffB9IvmCSEE9ZFXplO6AyCfKsUjazUL/G7Cq4WN1diV4K376hY6UbFzSHcZ1K4UTvD1uzqZI2Pa1bwA==",
"requires": {
"cross-fetch": "^4.1.0"
}
diff --git a/with-express-js/package.json b/with-express-js/package.json
index ab39864..5bf1446 100644
--- a/with-express-js/package.json
+++ b/with-express-js/package.json
@@ -11,7 +11,7 @@
"author": "Lakhan Samani",
"license": "ISC",
"dependencies": {
- "@authorizerdev/authorizer-js": "^3.2.1",
+ "@authorizerdev/authorizer-js": "^3.3.0",
"express": "^4.18.2"
}
}
From b9d5e193cd6c74d57abdb473f276f2cdab208c08 Mon Sep 17 00:00:00 2001
From: Lakhan Samani
Date: Tue, 4 Aug 2026 16:06:43 +0530
Subject: [PATCH 04/10] fix(auth-recipes): repair server flags, MFA offer and
removed signup field
run-server.sh would not start at all: --enable-mfa and --enable-totp-login
no longer exist. Since 2.4.0 both are on by default and the opt-outs are
--disable-*. --enforce-mfa=false is now the default too, but it stays
spelled out because recipe 2 depends on enrollment being offered rather
than forced.
Recipe 1 then broke a step later. verify_email now stops at the MFA setup
offer and withholds the access token, so the profile query ran with
"Bearer null" and 401'd. It declines via skip_mfa_setup, using the
setCookies/cookieHeader helpers lib/common.mjs already had for exactly
this.
Recipe 2 could not sign up: is_multi_factor_auth_enabled was removed from
SignUpRequest as a security fix -- an unauthenticated caller choosing
whether MFA applies to the account it is creating defeats the
MFA-on-by-default policy. The recipe does not need it now that MFA is the
default.
Recipe 3 also left the user mid-MFA, so it now declines the offer as well.
That does NOT make its webhook fire, and the comment says so: with email
verification and MFA both on, user.signup never fires, because
verify_email returns from the MFA gate before its own RegisterEvent and
skip_mfa_setup issues its auth response with isSignUp=false. Only
user.login is emitted. Documented in the recipe README with the
alternatives; this one is a server fix, not an example fix.
Recipes 1, 2 and 4 verified green against a local server + Mailpit.
Recipe 3's delivery leg is unverified -- it needs a sudo loopback alias.
Also bump the vanilla-js UMD CDN pins from authorizer-js 3.2.1 to 3.3.0.
---
with-auth-recipes/1-magic-link/magic-link.mjs | 39 +++++++++++++++----
with-auth-recipes/2-totp-mfa/totp-mfa.mjs | 20 +++++-----
with-auth-recipes/3-webhooks/README.md | 9 +++++
with-auth-recipes/3-webhooks/webhook-demo.mjs | 29 +++++++++++++-
with-auth-recipes/README.md | 10 +++--
with-auth-recipes/run-server.sh | 2 -
with-vanilla-js-custom-ui/index.html | 2 +-
with-vanilla-js-custom-ui/login.html | 2 +-
with-vanilla-js/index.html | 2 +-
9 files changed, 86 insertions(+), 29 deletions(-)
diff --git a/with-auth-recipes/1-magic-link/magic-link.mjs b/with-auth-recipes/1-magic-link/magic-link.mjs
index f1365cd..958c523 100644
--- a/with-auth-recipes/1-magic-link/magic-link.mjs
+++ b/with-auth-recipes/1-magic-link/magic-link.mjs
@@ -6,6 +6,7 @@
import {
gql,
clearMailbox,
+ cookieHeader,
waitForEmail,
extractVerificationToken,
randomEmail,
@@ -33,19 +34,41 @@ console.log('token (first 40 chars):', token.slice(0, 40), '...');
// 3. Exchange the token for a session. (Clicking the link in the email does
// the same thing via GET /verify_email, then redirects to redirect_uri.)
-const { data: verified } = await gql(
+const sessionFields = `
+ message
+ access_token
+ expires_in
+ user { id email signup_methods email_verified }
+`;
+
+const { data: verified, setCookies } = await gql(
`mutation ($params: VerifyEmailRequest!) {
- verify_email(params: $params) {
- message
- access_token
- expires_in
- user { id email signup_methods email_verified }
- }
+ verify_email(params: $params) { ${sessionFields} }
}`,
{ params: { token } }
);
-const auth = verified.verify_email;
+let auth = verified.verify_email;
console.log('verify_email:', auth.message);
+
+// Since 2.4.0 MFA is on by default, so verify_email enrols nothing but OFFERS
+// an MFA setup: it returns no access token and the message "Proceed to mfa
+// setup" until the user either enrols a factor or explicitly declines. This
+// recipe declines, which is what skip_mfa_setup is for -- it records the
+// refusal and releases the withheld token. The call is identified by the MFA
+// session cookie the response above just set, plus the email. Under
+// --enforce-mfa declining is refused and the user must enrol instead; see
+// 2-totp-mfa for that path.
+if (!auth.access_token) {
+ const { data: skipped } = await gql(
+ `mutation ($params: SkipMfaSetupRequest!) {
+ skip_mfa_setup(params: $params) { ${sessionFields} }
+ }`,
+ { params: { email } },
+ { Cookie: cookieHeader(setCookies) }
+ );
+ auth = skipped.skip_mfa_setup;
+ console.log('skip_mfa_setup:', auth.message);
+}
console.log('user:', auth.user);
// 4. Prove the session: authenticated profile query.
diff --git a/with-auth-recipes/2-totp-mfa/totp-mfa.mjs b/with-auth-recipes/2-totp-mfa/totp-mfa.mjs
index c0fc391..041cde1 100644
--- a/with-auth-recipes/2-totp-mfa/totp-mfa.mjs
+++ b/with-auth-recipes/2-totp-mfa/totp-mfa.mjs
@@ -1,12 +1,17 @@
// TOTP multi-factor auth, end to end:
-// 1. signup with is_multi_factor_auth_enabled → verification email
+// 1. signup → verification email
// 2. verify_email → server starts TOTP enrollment: returns the shared secret
// (+ QR image + recovery codes) and sets an `mfa_session` cookie
// 3. generate a code from the secret (otpauth lib) → verify_otp(is_totp) with
// the mfa cookie → enrolled + first session
// 4. fresh login → TOTP challenge again → verify_otp → session → profile
//
-// Server must run with --enable-mfa --enable-totp-login (see ../run-server.sh).
+// Since 2.4.0 MFA and TOTP are on by default, so nothing has to be switched on
+// for this recipe (see ../run-server.sh). Signup used to opt the new user in
+// with is_multi_factor_auth_enabled, but that field was removed as a security
+// fix: letting an unauthenticated caller decide whether MFA applies to the
+// account they are creating defeats the server's MFA-on-by-default policy.
+// For an existing user the admin `_update_user` path is now the only override.
import * as OTPAuth from 'otpauth';
import {
gql,
@@ -33,20 +38,13 @@ const AUTH_RESPONSE = `
user { id email }
`;
-// 1. Sign up with MFA enabled for this user.
+// 1. Sign up. MFA applies because the server has it on by default.
await clearMailbox();
const { data: signup } = await gql(
`mutation ($params: SignUpRequest!) {
signup(params: $params) { message }
}`,
- {
- params: {
- email,
- password,
- confirm_password: password,
- is_multi_factor_auth_enabled: true,
- },
- }
+ { params: { email, password, confirm_password: password } }
);
console.log('signup:', signup.signup.message);
diff --git a/with-auth-recipes/3-webhooks/README.md b/with-auth-recipes/3-webhooks/README.md
index 3d4fb89..32d06f5 100644
--- a/with-auth-recipes/3-webhooks/README.md
+++ b/with-auth-recipes/3-webhooks/README.md
@@ -31,6 +31,15 @@ signup otherwise), `user.login`, `user.deleted`, `user.deactivated`,
`user.access_revoked`, `user.access_enabled`.
Delivery attempts are recorded and queryable via `_webhook_logs`.
+> **Known gap since 2.4.0.** With email verification *and* MFA both on — the
+> configuration `run-server.sh` uses, and the default for MFA — `user.signup`
+> never fires. `verify_email` returns from the MFA gate before reaching its
+> own event registration, and `skip_mfa_setup` issues its auth response with
+> `isSignUp=false`, so the path emits only `user.login`. Until the server
+> carries the signup flag through the MFA session, subscribe to `user.created`
+> (fires at signup, before verification) or `user.login` instead. This recipe
+> still registers `user.signup` because that is the event it is about.
+
## SSRF protection vs. local testing
Authorizer refuses webhook endpoints on loopback/private networks (127/8,
diff --git a/with-auth-recipes/3-webhooks/webhook-demo.mjs b/with-auth-recipes/3-webhooks/webhook-demo.mjs
index da094d2..3f967a3 100644
--- a/with-auth-recipes/3-webhooks/webhook-demo.mjs
+++ b/with-auth-recipes/3-webhooks/webhook-demo.mjs
@@ -20,6 +20,7 @@ import {
gql,
adminHeaders,
clearMailbox,
+ cookieHeader,
waitForEmail,
extractVerificationToken,
randomEmail,
@@ -103,12 +104,36 @@ try {
{ params: { email, password, confirm_password: password } }
);
const token = extractVerificationToken(await waitForEmail(email));
- await gql(
- `mutation ($params: VerifyEmailRequest!) { verify_email(params: $params) { message } }`,
+ const { data: verified, setCookies } = await gql(
+ `mutation ($params: VerifyEmailRequest!) {
+ verify_email(params: $params) { message access_token }
+ }`,
{ params: { token } }
);
console.log('signup + verify_email done for', email);
+ // Since 2.4.0 MFA is on by default, so verify_email stops at an MFA setup
+ // OFFER and withholds the access token; decline it to finish the login.
+ // Identified by the MFA session cookie set above.
+ //
+ // KNOWN GAP (server-side, not fixable here): with email verification AND
+ // MFA both on, `user.signup` never fires. verify_email returns from the MFA
+ // gate before it reaches its own RegisterEvent, and skip_mfa_setup issues
+ // its auth response with isSignUp=false, so only `user.login` is emitted.
+ // Until the server carries the signup flag through the MFA session, use
+ // `user.created` (fires at signup, pre-verification) or `user.login` if you
+ // need an event on this path. See this recipe's README.
+ if (!verified.verify_email.access_token) {
+ await gql(
+ `mutation ($params: SkipMfaSetupRequest!) {
+ skip_mfa_setup(params: $params) { message }
+ }`,
+ { params: { email } },
+ { Cookie: cookieHeader(setCookies) }
+ );
+ console.log('declined the mfa setup offer, login complete');
+ }
+
// 4. Wait for the delivery and verify the signature.
const { raw, signature } = await Promise.race([
delivery,
diff --git a/with-auth-recipes/README.md b/with-auth-recipes/README.md
index 3aaad47..8515fc4 100644
--- a/with-auth-recipes/README.md
+++ b/with-auth-recipes/README.md
@@ -45,11 +45,15 @@ If the server runs on a non-default port, point the scripts at it:
--smtp-host=localhost --smtp-port=1025 --smtp-sender-email=... # Mailpit
--enable-email-verification # emails on signup
--enable-magic-link-login # recipe 1, 4
---enable-mfa --enable-totp-login --enforce-mfa=false # recipe 2
+--enforce-mfa=false # recipe 2
```
-`--enforce-mfa=false` matters: it defaults to `true`, which would force TOTP
-onto every user and entangle the other recipes.
+Since 2.4.0 MFA and TOTP are on by default, so the `--enable-mfa` and
+`--enable-totp-login` flags this script used to pass no longer exist — the
+opt-outs are `--disable-mfa` / `--disable-totp-login`. `--enforce-mfa=false`
+is now also the default; it stays spelled out because recipe 2 depends on
+enrollment being *offered* rather than *forced*, and enforcing it would
+entangle the other recipes.
All secrets in `run-server.sh` (admin secret `admin`, client id/secret, JWT
secret) are throwaway dev values — never reuse them.
diff --git a/with-auth-recipes/run-server.sh b/with-auth-recipes/run-server.sh
index e6a24d7..f38fba8 100755
--- a/with-auth-recipes/run-server.sh
+++ b/with-auth-recipes/run-server.sh
@@ -34,6 +34,4 @@ exec go run main.go \
--organization-name="Acme Local" \
--enable-email-verification \
--enable-magic-link-login \
- --enable-mfa \
- --enable-totp-login \
--enforce-mfa=false
diff --git a/with-vanilla-js-custom-ui/index.html b/with-vanilla-js-custom-ui/index.html
index 74a398c..0fea427 100644
--- a/with-vanilla-js-custom-ui/index.html
+++ b/with-vanilla-js-custom-ui/index.html
@@ -39,7 +39,7 @@ Foo Bar!
mollit anim id est laborum.
-
+
+
+