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
10 changes: 10 additions & 0 deletions .changeset/client-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@seamless-auth/client': minor
'@seamless-auth/react': minor
'@seamless-auth/react-native': minor
'@seamless-auth/angular': minor
'@seamless-auth/vue': minor
'@seamless-auth/svelte': minor
---

Add `hasPermission`, `hasAllPermissions`, `hasRole` and `hasOrganizationRole`, which read the permissions and roles the `/users/me` user carries, and a `usePermissions` hook in `@seamless-auth/react`. A permission is held if the instance or the active organization grants it, and `{ organization: true }` counts only the active organization, matching the server's `requirePermission`. Permission and organization role matching is exact, instance roles keep their scoped matching, and an organization role never satisfies an instance role check. Fields an older API leaves out answer false. These are for showing and hiding UI only: the server must still enforce. They reflect the last `/users/me` read, so a change reaches the client when the user is next refreshed.
7 changes: 7 additions & 0 deletions .changeset/types-0-38-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@seamless-auth/client': patch
'@seamless-auth/react': patch
'@seamless-auth/react-native': patch
---

Depend on `@seamless-auth/types` `^0.38.0`, which adds permissions and organization roles to the `/users/me` user.
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ that package rather than redeclaring shapes.
Rules for this dependency:

- types only. Import with `import type` so the package's Zod dependency never
reaches the browser bundle. There is a `Record<OAuthErrorCode, true>` in
reaches the browser bundle. The one runtime import is the Zod-free
`@seamless-auth/types/permission/matching` in `packages/client/src/permissions.ts`,
which is external in the client build and mapped in each Jest config. Nothing
checks this automatically, so never import the package root at runtime. There is a `Record<OAuthErrorCode, true>` in
`packages/client/src/client/errors.ts` that exists for exactly this reason: it is a
compile-time membership check standing in for the upstream runtime list.
- keep the SDK's own export names. Adopters import `Credential` from this
Expand Down Expand Up @@ -158,6 +161,7 @@ Runtime exports currently include:
- `useAuthClient`
- `usePasskeySupport`
- `hasScopedRole` and `roleGrantsAccess`
- `hasPermission`, `hasAllPermissions`, `hasRole`, `hasOrganizationRole`, and `usePermissions` (React only). `hasRole` is instance roles with scoped matching, unlike the exact `useAuth().hasRole`
- `encodePrfSalt`, `extractPasskeyPrfResult`, and `isPasskeyPrfSupported`
- `SeamlessAuthError`, `getOAuthErrorCode`, `getPasskeyPolicyErrorCode`, and `getWebAuthnErrorDetail`

Expand Down Expand Up @@ -220,6 +224,9 @@ This repository is an npm workspace. Its published packages include:
- `/auth` request construction
- `src/scopedRoles.ts`
- role matching, kept byte-for-byte with `@seamless-auth/types/role/matching`
- `src/permissions.ts`
- UI-only checks over the `/users/me` user: exact permission and organization role
matching, scoped instance roles, false for anything an older API leaves out
- `src/types.ts`
- aliases of the wire contract in `@seamless-auth/types`, not hand-written shapes

Expand All @@ -232,6 +239,8 @@ This repository is an npm workspace. Its published packages include:
- creates a memoized client from provider configuration
- `src/hooks/usePasskeySupport.ts`
- exposes browser support detection for passkeys
- `src/hooks/usePermissions.ts`
- permission and role checks bound to the provider's current user
- `src/AuthRoutes.tsx`
- bundles the prebuilt auth route flow
- `src/routes.ts`
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

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

8 changes: 8 additions & 0 deletions packages/angular/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ Also: `hasRole`, `hasScopedRole`, `markSignedIn`, `whenSettled()` (resolves once
fetch for your own API that carries the cookies to `apiHost` and `trustedOrigins` only), `client` (the headless client for everything
else, such as OTP and magic link calls), and `ports`.

### Permissions and organization roles

`hasPermission(user, required, { organization? })`, `hasAllPermissions`, `hasRole` (instance roles, scoped
matching) and `hasOrganizationRole` (active organization, exact matching) are exported for showing
and hiding UI. They are for UI only: the server must still enforce. They reflect the last
`/users/me` read, so a change reaches the client when the user is next refreshed. See the
[`@seamless-auth/client` README](../client/README.md#permissions-and-roles).

## Built-in screens

`@seamless-auth/angular/routes` exports `seamlessAuthRoutes` and the standalone components behind
Expand Down
10 changes: 9 additions & 1 deletion packages/angular/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,21 @@ export default {
rootDir: '.',
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
transform: {
'/node_modules/@seamless-auth/types/.+\\.js$': [
'ts-jest',
{ tsconfig: { allowJs: true } },
],
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{ tsconfig: '<rootDir>/tsconfig.spec.json', stringifyContentPathRegex: '\\.html$' },
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
transformIgnorePatterns: ['node_modules/(?!(.*\\.mjs$|@seamless-auth/types/))'],
moduleNameMapper: {
// `@seamless-auth/types` ships ESM under the `import` condition alone, which
// Jest's CommonJS resolver cannot read.
'^@seamless-auth/types/permission/matching$':
'<rootDir>/../../node_modules/@seamless-auth/types/dist/schemas/permission/matching.js',
// Tests run against the sources, so a change in the core is exercised here
// without a build in between.
'^@seamless-auth/angular$': '<rootDir>/src/index.ts',
Expand Down
6 changes: 6 additions & 0 deletions packages/angular/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export {
getOAuthErrorCode,
getPasskeyPolicyErrorCode,
getWebAuthnErrorDetail,
hasAllPermissions,
hasOrganizationRole,
hasPermission,
hasRole,
hasScopedRole,
isUnauthenticated,
roleGrantsAccess,
Expand All @@ -33,9 +37,11 @@ export type {
OAuthRedirectPort,
Organization,
PasskeyPort,
PermissionCheckOptions,
RefreshSessionOptions,
SeamlessAuthClient,
SeamlessAuthResult,
StepUpStatus,
User,
UserAuthorization,
} from '@seamless-auth/client';
22 changes: 22 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ npm install @seamless-auth/client
- PRF helpers for passkey-derived secrets.
- `hasScopedRole` / `roleGrantsAccess`, the same role matching the auth API and
server adapters apply.
- `hasPermission`, `hasAllPermissions`, `hasRole` and `hasOrganizationRole`, which
read the `/users/me` user for showing and hiding UI. See
[Permissions and roles](#permissions-and-roles).
- Flows: the step logic behind the bundled sign-in screens, so every binding
behaves the same and none reimplements it. See below.

Expand Down Expand Up @@ -106,6 +109,25 @@ and `startOAuthSignIn` reports it as a provider that would not start. A custom
`OAuthRedirectPort` can apply the same check with
`assertNavigableAuthorizationUrl(url)`.

## Permissions and roles

**For UI only. The server must still enforce.** These read the last `/users/me`
response, so a change reaches the client when the user is next refreshed. The
client never decodes a token.

```ts
hasPermission(user, 'reports:read'); // instance or active organization grant
hasPermission(user, 'projects:write', { organization: true }); // active organization only
hasAllPermissions(user, ['reports:read', 'projects:write']);
hasRole(user, 'admin:read'); // instance roles, scoped like hasScopedRole
hasOrganizationRole(user, ['owner', 'admin']); // active organization roles, exact
```

Permission and organization role matching is exact, with no wildcards or implied
grants. An organization `admin` never satisfies `hasRole('admin')`. A field an older
API leaves out means unknown, and every check against it, or against a missing
user, answers `false`.

## Transport

`createSeamlessAuthClient` takes a `transport` option:
Expand Down
9 changes: 9 additions & 0 deletions packages/client/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,19 @@ export default {
rootDir: '.',
setupFilesAfterEnv: ['<rootDir>/../../jest.setup.ts'],
transform: {
'/node_modules/@seamless-auth/types/.+\\.js$': [
'ts-jest',
{ tsconfig: { allowJs: true } },
],
'^.+\\.(t|j)sx?$': ['ts-jest', { useESM: true, tsconfig: '<rootDir>/tsconfig.json' }],
},
transformIgnorePatterns: ['/node_modules/(?!@seamless-auth/types/)'],
extensionsToTreatAsEsm: ['.ts', '.tsx'],
moduleNameMapper: {
// `@seamless-auth/types` ships ESM under the `import` condition alone, which
// Jest's CommonJS resolver cannot read.
'^@seamless-auth/types/permission/matching$':
'<rootDir>/../../node_modules/@seamless-auth/types/dist/schemas/permission/matching.js',
'^@/(.*)$': '<rootDir>/src/$1',
},
testMatch: ['<rootDir>/tests/**/*.(test|spec).[tj]s?(x)'],
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"provenance": true
},
"dependencies": {
"@seamless-auth/types": "^0.36.0",
"@seamless-auth/types": "^0.38.0",
"@simplewebauthn/browser": "^13.1.0",
"libphonenumber-js": "^1.12.7"
},
Expand Down
6 changes: 5 additions & 1 deletion packages/client/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ export default [
format: 'esm',
sourcemap: true,
},
external: ['@simplewebauthn/browser', 'libphonenumber-js'],
external: [
'@seamless-auth/types/permission/matching',
'@simplewebauthn/browser',
'libphonenumber-js',
],
plugins: [
alias({
entries: [{ find: '@', replacement: path.resolve(__dirname, 'src') }],
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './client/webauthnPrf';
export * from './client/webauthnSupport';
export * from './fetchWithAuth';
export * from './flows';
export * from './permissions';
export * from './ports/browserPasskeys';
export * from './ports/oauthRedirect';
export * from './ports/passkeys';
Expand Down
95 changes: 95 additions & 0 deletions packages/client/src/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the Apache License, Version 2.0
* See LICENSE file in the project root for full license information
*/

// This subpath has no Zod import. The root of `@seamless-auth/types` does, so it
// stays `import type` everywhere in this package.
import {
hasAllPermissions as includesAllOf,
hasPermission as includesAnyOf,
} from '@seamless-auth/types/permission/matching';

import { hasScopedRole } from './scopedRoles';
import type { User } from './types';

/**
* The fields these helpers read from the signed-in user. An older API leaves the
* permission and organization fields out, which means unknown rather than none,
* so every check against them answers false.
*/
export type UserAuthorization = Partial<
Pick<User, 'roles' | 'permissions' | 'organizationRoles' | 'organizationPermissions'>
>;

export interface PermissionCheckOptions {
/**
* `true` counts only what the active organization grants, leaving out
* instance-wide permissions. By default a permission is held if either grants it.
*/
organization?: boolean;
}

function heldPermissions(
user: UserAuthorization | null | undefined,
options: PermissionCheckOptions | undefined
): unknown[] {
const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
const organization = list(user?.organizationPermissions);

return options?.organization
? organization
: [...list(user?.permissions), ...organization];
}

/**
* Whether the user holds at least one of the required permissions. Matching is
* exact: `invoices:write` does not satisfy `invoices:read`.
*
* For showing and hiding UI only. The server must still enforce.
*/
export function hasPermission(
user: UserAuthorization | null | undefined,
required: string | string[],
options?: PermissionCheckOptions
): boolean {
return includesAnyOf(heldPermissions(user, options), required);
}

/**
* Whether the user holds every required permission. Requiring nothing answers
* false. For showing and hiding UI only. The server must still enforce.
*/
export function hasAllPermissions(
user: UserAuthorization | null | undefined,
required: string | string[],
options?: PermissionCheckOptions
): boolean {
return includesAllOf(heldPermissions(user, options), required);
}

/**
* Whether the user's instance roles satisfy at least one required role, with the
* scoped matching of `hasScopedRole`. Organization roles never count: an
* organization `admin` is not an instance `admin`. Use `hasOrganizationRole` for
* those. For showing and hiding UI only. The server must still enforce.
*/
export function hasRole(
user: UserAuthorization | null | undefined,
required: string | string[]
): boolean {
return hasScopedRole(user?.roles, required);
}

/**
* Whether the user holds at least one of the required roles in the active
* organization. Matching is exact and instance roles never count. For showing and
* hiding UI only. The server must still enforce.
*/
export function hasOrganizationRole(
user: UserAuthorization | null | undefined,
required: string | string[]
): boolean {
return includesAnyOf(user?.organizationRoles, required);
}
Loading
Loading