From 9de3e67c1f20dcd4028cb4d96f69549d7dd3e3b0 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 6 Jun 2026 10:28:01 +0530 Subject: [PATCH] feat: add fine-grained authorization (FGA) support Add client-facing FGA capabilities mirroring the server's authorization API: - required_permissions (PermissionInput[], AND semantics) on SessionQueryRequest, ValidateJWTTokenRequest and ValidateSessionRequest - getPermissions(headers) wrapping the permissions query, returning the authenticated principal's granted resource:scope permissions - Permission / PermissionInput types - Integration tests for getPermissions and required-permissions validation - README usage section --- README.md | 30 ++++++++++++++++++++++++++++++ __test__/index.test.ts | 25 +++++++++++++++++++++++++ src/index.ts | 21 +++++++++++++++++++++ src/types.ts | 19 +++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/README.md b/README.md index 5510d39..d294a06 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,36 @@ async function main() { } ``` +## Fine-grained authorization (FGA) + +Authorizer supports resource:scope based fine-grained permissions. The SDK exposes them in two ways. + +**1. Assert required permissions while validating** — pass `required_permissions` to `getSession`, `validateJWTToken` or `validateSession`. They are evaluated with AND semantics: every entry must be granted, otherwise the result is unauthorized. + +```js +const { data } = await authRef.validateJWTToken({ + token_type: 'access_token', + token, + required_permissions: [ + { resource: 'documents', scope: 'read' }, + { resource: 'documents', scope: 'write' }, + ], +}); + +if (!data?.is_valid) { + // unauthorized +} +``` + +**2. Fetch the principal's granted permissions** — `getPermissions` returns the resource:scope permissions for the authenticated principal. It uses the session cookie by default; in node.js pass the authorization header. + +```js +const { data: permissions } = await authRef.getPermissions({ + Authorization: `Bearer ${token}`, +}); +// permissions => [{ resource: 'documents', scope: 'read' }, ...] +``` + ## Local Development Setup ### Prerequisites diff --git a/__test__/index.test.ts b/__test__/index.test.ts index b5d0daf..3a82d65 100644 --- a/__test__/index.test.ts +++ b/__test__/index.test.ts @@ -181,6 +181,31 @@ describe('Integration Tests - authorizer-js', () => { expect(validateRes?.data?.is_valid).toEqual(true); }); + it('should mark token invalid when required_permissions are missing', async () => { + expect(loginRes?.data?.access_token).toBeDefined(); + expect(loginRes?.data?.access_token).not.toBeNull(); + // A new user lacks the documents:read permission, so asserting it via + // required_permissions (AND semantics) must mark the token as not valid. + const validateRes = await authorizer.validateJWTToken({ + token_type: 'access_token', + token: loginRes?.data?.access_token || '', + required_permissions: [{ resource: 'documents', scope: 'read' }], + }); + expect(validateRes?.errors).toHaveLength(0); + expect(validateRes?.data?.is_valid).toEqual(false); + }); + + it('should fetch permissions for the authenticated user', async () => { + expect(loginRes?.data?.access_token).toBeDefined(); + expect(loginRes?.data?.access_token).not.toBeNull(); + const permissionsRes = await authorizer.getPermissions({ + Authorization: `Bearer ${loginRes?.data?.access_token}`, + }); + expect(permissionsRes?.errors).toHaveLength(0); + // A freshly signed up user has no fine-grained permissions assigned. + expect(permissionsRes?.data).toEqual([]); + }); + it('should update profile successfully', async () => { expect(loginRes?.data?.access_token).toBeDefined(); expect(loginRes?.data?.access_token).not.toBeNull(); diff --git a/src/index.ts b/src/index.ts index 5475d4b..e8483e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -245,6 +245,27 @@ export class Authorizer { } }; + // fetch the fine-grained resource:scope permissions granted to the + // authenticated principal. Uses the session cookie by default; when running + // in node.js pass the authorization header. + getPermissions = async ( + headers?: Types.Headers, + ): Promise> => { + try { + const res = await this.graphqlQuery({ + query: 'query permissions { permissions { resource scope } }', + headers, + operationName: 'permissions', + }); + + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data?.permissions); + } catch (error) { + return this.errorResponse([error]); + } + }; + // this is used to verify / get session using cookie by default. If using node.js pass authorization header getSession = async ( headers?: Types.Headers, diff --git a/src/types.ts b/src/types.ts index 3336ff2..9422c08 100644 --- a/src/types.ts +++ b/src/types.ts @@ -285,10 +285,27 @@ export interface DeleteUserRequest { email: string; } +// Fine-grained authorization (FGA) types +// PermissionInput is a resource:scope pair asserted as a required permission. +// Required permissions are evaluated with AND semantics — every entry must be +// granted, otherwise the principal is treated as unauthorized. +export interface PermissionInput { + resource: string; + scope: string; +} + +// Permission is a resource:scope permission granted to a principal, +// returned by the permissions query. +export interface Permission { + resource: string; + scope: string; +} + // SessionQueryRequest export interface SessionQueryRequest { roles?: string[] | null; scope?: string[] | null; + required_permissions?: PermissionInput[] | null; } // Keep SessionQueryInput as alias for backward compatibility @@ -299,6 +316,7 @@ export interface ValidateJWTTokenRequest { token_type: string; token: string; roles?: string[] | null; + required_permissions?: PermissionInput[] | null; } // Keep ValidateJWTTokenInput as alias for backward compatibility @@ -314,6 +332,7 @@ export interface ValidateJWTTokenResponse { export interface ValidateSessionRequest { cookie: string; roles?: string[] | null; + required_permissions?: PermissionInput[] | null; } // Keep ValidateSessionInput as alias for backward compatibility