Auth with Logto: tokens, scopes, and RBAC end-to-end
This recipe wires up a Nwire app to use Logto as its identity provider, with CASL enforcing fine-grained scopes + roles per request.
The architecture:
HTTP request
↓ Authorization: Bearer <jwt>
identityPlugin (extracts token, calls adapter.verifyToken)
↓ User attached to envelope
rbacPlugin (builds Ability from user.scopes + user.roles)
↓ Ability stashed on ctx
Resolver.use(can("create", "Post")) ← gate
↓
Handler runs1. Start Logto + Postgres locally
The project ships a docker-compose.yml with Postgres, Redis, Mongo, Mailhog, and Logto:
nwire infra up
# Postgres → :5432
# Redis → :6379
# Mongo → :27017
# Mailhog → http://localhost:8025
# Logto → http://localhost:3001 (tenant API)
# → http://localhost:3002 (admin console)On first boot, Logto seeds itself; open the admin console at http://localhost:3002 and:
- Create an API Resource named e.g.
https://api.my-app.com(this is youraudience). - Under "Permissions" on that resource, add scopes:
read:posts,write:posts,manage:users. - Create an Application ("Traditional web" or "Single page app" depending on your frontend).
- Note the App ID and (for confidential clients) App Secret.
- Optional: under "Roles", create roles like
admin/editor/viewerand assign scopes to each. Logto's tokens will carry the role names in arolesclaim.
2. Wire the adapter
pnpm add @nwire/auth @nwire/auth-logto @nwire/rbac// src/auth/index.ts
import { identityPlugin } from "@nwire/auth";
import { logtoAdapter } from "@nwire/auth-logto";
import { rbacPlugin, defineAbility } from "@nwire/rbac";
const adapter = logtoAdapter({
endpoint: process.env.LOGTO_ENDPOINT!, // http://localhost:3001 in dev
audience: process.env.LOGTO_AUDIENCE!, // https://api.my-app.com
clientId: process.env.LOGTO_CLIENT_ID!,
clientSecret: process.env.LOGTO_SECRET, // optional for public SPAs
fetchUserInfo: true, // grab email/name from /oidc/me
});
export const buildAbility = defineAbility((user, { allow }) => {
if (!user) return; // anon: deny everything
// Logto roles → coarse-grained
if (user.roles?.includes("admin")) { allow("manage", "all"); return; }
// Logto scopes → fine-grained per-resource permissions
if (user.scopes?.includes("read:posts")) allow("read", "Post");
if (user.scopes?.includes("write:posts")) {
allow("create", "Post");
allow("update", "Post", { authorId: user.id });
allow("delete", "Post", { authorId: user.id });
}
if (user.scopes?.includes("manage:users")) allow("manage", "User");
});3. Mount it
// src/app.ts
import { defineApp } from "@nwire/forge";
import { identityPlugin } from "@nwire/auth";
import { rbacPlugin } from "@nwire/rbac";
import { httpInterface, post, get } from "@nwire/http";
import { adapter, buildAbility } from "./auth/index.js";
import { postsModule } from "./posts/index.js";
export const app = defineApp("my-app", {
modules: [postsModule],
plugins: [
identityPlugin({ adapter }),
rbacPlugin({ buildAbility }),
],
});
await httpInterface({
prefix: "/api",
port: Number(process.env.PORT ?? 3000),
})
.wire(app)
.wire(post("/auth/sign-out"), ({ input }) => app.dispatch("SignOut", input))
.wire(post("/auth/refresh"), ({ input }) => app.dispatch("Refresh", input))
.wire(post("/auth/me"), ({ input }) => app.dispatch("Me", input))
.wire(post("/posts"), ({ input }) => app.dispatch("posts.Create", input))
.wire(post("/posts/:id"), ({ input }) => app.dispatch("posts.Update", input))
.wire(get("/posts"), ({ input }) => app.dispatch("posts.List", input))
.run();4. Gate actions
Declarative — route policy:
import { post } from "@nwire/http";
httpInterface()
.wire(
post("/posts", {
body: z.object({ title: z.string(), body: z.string() }),
policy: ["create", "Post"], // rbacPlugin enforces before the handler runs
}),
async ({ input, dispatch }) => dispatch("posts.create", input),
)
.run();Declarative — action tuple policy:
export const deletePost = defineAction({
name: "posts.delete",
schema: z.object({ id: z.string() }),
policy: ["delete", "Post"], // checked before the handler runs
handler: async (input) => {
await db.delete(posts).where(eq(posts.id, input.id));
return PostWasDeleted({ postId: input.id });
},
});Programmatic — instance-level conditions:
import { abilityFromCtx, subject } from "@nwire/rbac";
export const updatePost = defineAction({
name: "posts.update",
schema: z.object({ id: z.string(), title: z.string() }),
handler: async (input, ctx) => {
const post = await db.query.posts.findFirst({ where: eq(posts.id, input.id) });
if (!post) throw NotFound;
const ability = abilityFromCtx(ctx)!;
if (ability.cannot("update", subject("Post", post))) {
throw new ForbiddenError("not your post");
}
await db.update(posts).set({ title: input.title }).where(eq(posts.id, input.id));
return PostWasUpdated({ postId: input.id });
},
});Even though the user has the write:posts scope, the instance condition ({ authorId: user.id }) means they can only update their own posts. Logto handles authentication; CASL handles the row-level conditional.
5. Token extraction from Authorization header
@nwire/auth ships the adapter contract; HTTP-side token extraction (read Authorization: Bearer …, call adapter.verifyToken, populate envelope.user) is plumbed by the identityPlugin's middleware. If you need to bypass the plugin and do it explicitly, your transport middleware can do it directly:
// src/wire/identity-middleware.ts
import { adapter } from "../auth/index.js";
export const identityMiddleware = async (ctx, next) => {
const header = ctx.headers["authorization"];
if (header?.startsWith("Bearer ")) {
const token = header.slice(7);
ctx.envelope.user = await adapter.verifyToken(token);
ctx.envelope.userId = ctx.envelope.user?.id;
}
await next();
};6. Test it end-to-end
The Logto adapter ships with jose-minted JWT tests that don't need Logto running — fast, reliable, offline. For integration tests against the real Logto:
nwire infra up logto
LOGTO_E2E=1 pnpm test
nwire infra down # or `nwire infra reset` to wipeThe recipe scope covers everything Laravel-grade auth gives you:
| Capability | Owned by |
|---|---|
| Login / signup / password reset / MFA / social | Logto (hosted UI + admin console) |
| JWT issuance + revocation | Logto (OIDC tokens) |
| RBAC role assignment | Logto admin console |
| OAuth scope enforcement | Logto issues, CASL evaluates |
| Conditional / row-level rules | CASL (MongoDB-style queries) |
| Token verification | jose in @nwire/auth-logto |
| Lifecycle (sign-out, refresh) | @nwire/auth-logto wraps Logto's OIDC endpoints |
| Gate enforcement | @nwire/rbac dispatch middleware + resolver can() |
| Audit (who did what) | envelope.userId + envelope.user (already plumbed) |
No password hashing code. No JWT signing code. No role-management UI. Glue, not reinvent.