Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Jump straight to the capability you need.
| [Environment variables](./packages/auth0-express-api/EXAMPLES.md#using-environment-variables) | Configure from `AUTH0_*` env vars instead of hardcoding |
| [Protect an API route (`requiresAuth`)](./packages/auth0-express-api/README.md#protecting-api-routes) | Require a valid bearer access token |
| [Read token claims (`req.auth0.user`)](./packages/auth0-express-api/README.md#protecting-api-routes) | Access claims extracted from the verified token |
| [Require specific scopes](./packages/auth0-express-api/EXAMPLES.md#requiring-specific-scopes) | Gate routes with `scopesInclude` (match any or all) |
| [Require specific scopes](./packages/auth0-express-api/EXAMPLES.md#requiring-specific-scopes) | Gate routes with `scopesInclude` (match all or any) |
| [Authorization with claims](./packages/auth0-express-api/README.md#authorization-with-claims) | Restrict routes with `claimEquals`, `claimIncludes`, `claimCheck` |
| [Custom token / user type](./packages/auth0-express-api/README.md#custom-types) | Type your custom claims via module augmentation |
| [Custom `fetch`](./packages/auth0-express-api/EXAMPLES.md#configuring-a-customfetch-implementation) | Swap in your own fetch (proxies, retries, instrumentation) |
Expand Down
16 changes: 8 additions & 8 deletions packages/auth0-express-api/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,21 +155,21 @@ The `scopesInclude` middleware allows you to check for multiple scopes with flex
```ts
import { requiresAuth, scopesInclude } from '@auth0/auth0-express-api';

// Match ANY of the scopes (default behavior)
// Match ANY of the scopes
app.get(
'/messages',
requiresAuth(),
scopesInclude('read:messages read:admin'),
scopesInclude('read:messages read:admin', { match: 'any' }),
async (req, res) => {
res.json({ message: 'Access granted with either scope' });
}
);

// Match ALL of the scopes
// Match ALL of the scopes (default behavior)
app.get(
'/admin/edit',
requiresAuth(),
scopesInclude('read:admin write:admin', { match: 'all' }),
scopesInclude('read:admin write:admin'),
async (req, res) => {
res.json({ message: 'Access granted with both scopes' });
}
Expand All @@ -179,11 +179,11 @@ app.get(
You can also pass an array of scopes:

```ts
// Match ANY (default)
app.get('/messages', requiresAuth(), scopesInclude(['read:messages', 'read:admin']), handler);
// Match ANY
app.get('/messages', requiresAuth(), scopesInclude(['read:messages', 'read:admin'], { match: 'any' }), handler);

// Match ALL
app.get('/admin/edit', requiresAuth(), scopesInclude(['read:admin', 'write:admin'], { match: 'all' }), handler);
// Match ALL (default)
app.get('/admin/edit', requiresAuth(), scopesInclude(['read:admin', 'write:admin']), handler);
```

### Custom user type
Expand Down
8 changes: 4 additions & 4 deletions packages/auth0-express-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Jump straight to the capability you need.
| [Environment variables](./EXAMPLES.md#using-environment-variables) | Configure from `AUTH0_*` env vars instead of hardcoding |
| [Protect an API route (`requiresAuth`)](#protecting-api-routes) | Require a valid bearer access token |
| [Read token claims (`req.auth0.user`)](#protecting-api-routes) | Access claims extracted from the verified token |
| [Require specific scopes](./EXAMPLES.md#requiring-specific-scopes) | Gate routes with `scopesInclude` (match any or all) |
| [Require specific scopes](./EXAMPLES.md#requiring-specific-scopes) | Gate routes with `scopesInclude` (match all or any) |
| [Authorization with claims](#authorization-with-claims) | Restrict routes with `claimEquals`, `claimIncludes`, `claimCheck` |
| [Custom token / user type](#custom-types) | Type your custom claims via module augmentation |
| [Custom `fetch`](./EXAMPLES.md#configuring-a-customfetch-implementation) | Swap in your own fetch (proxies, retries, instrumentation) |
Expand Down Expand Up @@ -103,9 +103,9 @@ app.get('/premium', requiresAuth(), claimCheck(
'Premium tier or admin role required'
), handler);

// Flexible scope matching - match ANY (default) or ALL
app.get('/messages', requiresAuth(), scopesInclude('read:messages read:admin'), handler);
app.get('/admin/edit', requiresAuth(), scopesInclude('read:admin write:admin', { match: 'all' }), handler);
// Flexible scope matching - match ALL (default) or ANY
app.get('/admin/edit', requiresAuth(), scopesInclude('read:admin write:admin'), handler);
app.get('/messages', requiresAuth(), scopesInclude('read:messages read:admin', { match: 'any' }), handler);
```

See [EXAMPLES.md](https://github.com/auth0/auth0-express/blob/main/packages/auth0-express-api/EXAMPLES.md#authorization-with-claims) for more authorization patterns.
Expand Down
83 changes: 21 additions & 62 deletions packages/auth0-express-api/src/middleware/scopes-include.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,89 +23,40 @@ describe('scopesInclude', () => {
return res;
};

describe('match: "any" (default)', () => {
it('should call next() when token has one of the required scopes (string)', () => {
describe('match: "all" (default)', () => {
it('should call next() when token has all required scopes (string)', () => {
const middleware = scopesInclude('read:msg write:msg');
const req = createMockRequest({
sub: 'user123',
aud: 'api',
iss: 'issuer',
scope: 'read:msg',
});
const req = createMockRequest({ sub: 'user123', aud: 'api', iss: 'issuer', scope: 'read:msg write:msg' });
const res = createMockResponse();

middleware(req as Request, res as Response, mockNext);

expect(mockNext).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

it('should call next() when token has one of the required scopes (array)', () => {
it('should call next() when token has all required scopes (array)', () => {
const middleware = scopesInclude(['read:msg', 'write:msg']);
const req = createMockRequest({
sub: 'user123',
aud: 'api',
iss: 'issuer',
scope: 'write:msg',
});
const req = createMockRequest({ sub: 'user123', aud: 'api', iss: 'issuer', scope: 'read:msg write:msg delete:msg' });
const res = createMockResponse();

middleware(req as Request, res as Response, mockNext);

expect(mockNext).toHaveBeenCalled();
});

it('should call next() when token has multiple matching scopes', () => {
it('should return 403 by default when token has only some of the required scopes', () => {
const middleware = scopesInclude('read:msg write:msg');
const req = createMockRequest({
sub: 'user123',
aud: 'api',
iss: 'issuer',
scope: 'read:msg write:msg delete:msg',
});
const req = createMockRequest({ sub: 'user123', aud: 'api', iss: 'issuer', scope: 'read:msg' });
const res = createMockResponse();

middleware(req as Request, res as Response, mockNext);

expect(mockNext).toHaveBeenCalled();
});

it('should call next() with explicit match: "any"', () => {
const middleware = scopesInclude('read:msg write:msg', { match: 'any' });
const req = createMockRequest({
sub: 'user123',
aud: 'api',
iss: 'issuer',
scope: 'read:msg',
});
const res = createMockResponse();

middleware(req as Request, res as Response, mockNext);

expect(mockNext).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(mockNext).not.toHaveBeenCalled();
});

it('should return 403 when token has none of the required scopes', () => {
it('should return 403 by default when token has none of the required scopes', () => {
const middleware = scopesInclude('read:msg write:msg');
const req = createMockRequest({
sub: 'user123',
aud: 'api',
iss: 'issuer',
scope: 'delete:msg',
});
const req = createMockRequest({ sub: 'user123', aud: 'api', iss: 'issuer', scope: 'delete:msg' });
const res = createMockResponse();

middleware(req as Request, res as Response, mockNext);

expect(res.status).toHaveBeenCalledWith(403);
expect(res.header).toHaveBeenCalledWith(
'WWW-Authenticate',
expect.stringContaining('error="insufficient_scope"')
);
expect(res.header).toHaveBeenCalledWith(
'WWW-Authenticate',
expect.stringContaining('scope="read:msg write:msg"')
);
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('error="insufficient_scope"'));
expect(mockNext).not.toHaveBeenCalled();
});
});
Expand Down Expand Up @@ -180,6 +131,14 @@ describe('scopesInclude', () => {
});

describe('common behavior', () => {
it('should call next() with explicit match: "any" when token has one of several scopes', () => {
const middleware = scopesInclude('read:msg write:msg', { match: 'any' });
const req = createMockRequest({ sub: 'user123', aud: 'api', iss: 'issuer', scope: 'read:msg' });
const res = createMockResponse();
middleware(req as Request, res as Response, mockNext);
expect(mockNext).toHaveBeenCalled();
});

it('should return 403 when scope claim is missing', () => {
const middleware = scopesInclude('read:msg');
const req = createMockRequest({
Expand Down Expand Up @@ -242,7 +201,7 @@ describe('scopesInclude', () => {
sub: 'user123',
aud: 'api',
iss: 'issuer',
scope: ['read:msg', 'delete:msg'],
scope: ['read:msg', 'write:msg', 'delete:msg'],
});
const res = createMockResponse();

Expand Down
16 changes: 8 additions & 8 deletions packages/auth0-express-api/src/middleware/scopes-include.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { sendBearerError } from './claim-auth.js';
export interface ScopesIncludeOptions {
/**
* Match strategy:
* - 'any': Token must have at least one of the specified scopes (default)
* - 'all': Token must have all of the specified scopes
* - 'all': Token must have all of the specified scopes (default)
* - 'any': Token must have at least one of the specified scopes
*/
match?: 'any' | 'all';
}
Expand All @@ -16,20 +16,20 @@ export interface ScopesIncludeOptions {
*
* @param scopes - Space-separated string or array of scopes
* @param options - Configuration options
* @param options.match - Match strategy: 'any' (default) or 'all'
* @param options.match - Match strategy: 'all' (default) or 'any'
* @returns Express middleware function
*
* @example
* ```ts
* // Allow access if token has ANY of the specified scopes (default)
* router.get('/messages', requiresAuth(), scopesInclude('read:msg read:admin'), handler);
* // Require ALL of the specified scopes (default)
* router.get('/admin/edit', requiresAuth(), scopesInclude('read:admin write:admin'), handler);
*
* // Require ALL of the specified scopes
* router.get('/admin/edit', requiresAuth(), scopesInclude('read:admin write:admin', { match: 'all' }), handler);
* // Allow access if token has ANY of the specified scopes
* router.get('/messages', requiresAuth(), scopesInclude('read:msg read:admin', { match: 'any' }), handler);
* ```
*/
export function scopesInclude(scopes: string | string[], options: ScopesIncludeOptions = {}) {
const { match = 'any' } = options;
const { match = 'all' } = options;
let requiredScopes: string[];

if (typeof scopes === 'string') {
Expand Down
Loading