Skip to content

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 runs

1. Start Logto + Postgres locally

The project ships a docker-compose.yml with Postgres, Redis, Mongo, Mailhog, and Logto:

bash
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:

  1. Create an API Resource named e.g. https://api.my-app.com (this is your audience).
  2. Under "Permissions" on that resource, add scopes: read:posts, write:posts, manage:users.
  3. Create an Application ("Traditional web" or "Single page app" depending on your frontend).
  4. Note the App ID and (for confidential clients) App Secret.
  5. Optional: under "Roles", create roles like admin/editor/viewer and assign scopes to each. Logto's tokens will carry the role names in a roles claim.

2. Wire the adapter

bash
pnpm add @nwire/auth @nwire/auth-logto @nwire/rbac
ts
// 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

ts
// 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:

ts
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:

ts
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:

ts
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:

ts
// 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:

bash
nwire infra up logto
LOGTO_E2E=1 pnpm test
nwire infra down   # or `nwire infra reset` to wipe

The recipe scope covers everything Laravel-grade auth gives you:

CapabilityOwned by
Login / signup / password reset / MFA / socialLogto (hosted UI + admin console)
JWT issuance + revocationLogto (OIDC tokens)
RBAC role assignmentLogto admin console
OAuth scope enforcementLogto issues, CASL evaluates
Conditional / row-level rulesCASL (MongoDB-style queries)
Token verificationjose 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.

MIT licensed.