Skip to content

feat(auth0-server-js): Support scope mapping and base scopes#125

Open
jacobovidal wants to merge 15 commits into
mainfrom
feat/support-scope-records-mrrt
Open

feat(auth0-server-js): Support scope mapping and base scopes#125
jacobovidal wants to merge 15 commits into
mainfrom
feat/support-scope-records-mrrt

Conversation

@jacobovidal
Copy link
Copy Markdown
Contributor

@jacobovidal jacobovidal commented Feb 6, 2026

Description

This adds support for per-audience scope configuration in auth0-server-js, enabling flexible scope management for Multi-Resource Refresh Tokens (MRRT). Applications can now configure different base scopes for different APIs using a Record mapping.

Warning

PR Dependency
DO NOT MERGE until PR #124 is merged.

Changes in auth0-server-js

Per-audience scope configuration

The scope parameter in authorizationParams now supports both string and Record formats, allowing different base scopes for different audiences:

const serverClient = new ServerClient({
  authorizationParams: {
    scope: {
      'https://api1.example.com': 'read:api1 write:api1',
      'https://api2.example.com': 'read:api2 write:api2',
    }
  }
});

Scope handling behavior

At initialization, the ServerClient constructor normalizes the scope configuration to ensure consistent behavior. The scope handling follows these rules:

When NO scope is provided

Default scopes (email offline_access openid profile) are automatically set:

const serverClient = new ServerClient({
  authorizationParams: {
    audience: 'https://api.example.com'
    // No scope provided
  }
});

// Normalized to: 'email offline_access openid profile'
When a STRING scope is provided

The scope is preserved as-is without adding defaults (respects explicit configuration):

const serverClient = new ServerClient({
  authorizationParams: {
    scope: 'read:data write:data'
  }
});

// Preserved as-is: 'read:data write:data'
When a RECORD scope is provided

Each audience's scope is preserved as-is. Defaults are only added for the configured audience if not explicitly specified:

const serverClient = new ServerClient({
  authorizationParams: {
    audience: 'https://api1.example.com',
    scope: {
      'https://api1.example.com': 'read:api1 write:api1',
      'https://api2.example.com': 'read:api2 write:api2',
    }
  }
});

// Scope for api1: 'read:api1 write:api1' (preserved as-is)
// Scope for api2: 'read:api2 write:api2' (preserved as-is)

If the configured audience is not present in the Record, defaults are added:

const serverClient = new ServerClient({
  authorizationParams: {
    audience: 'https://api1.example.com',
    scope: {
      'https://api2.example.com': 'read:api2 write:api2',
    }
  }
});

// Normalized to include defaults for api1:
// {
//   'https://api1.example.com': 'email offline_access openid profile',
//   'https://api2.example.com': 'read:api2 write:api2'
// }

Note

This behavior matches nextjs-auth0: explicit scope configuration is respected, and defaults are only injected when nothing is specified.

Note

Although auth0-auth-js injects default scopes, we added auth0-server-js specific server-side defaults. This is intentional, as auth0-spa-js will eventually be based on auth0-auth-js, and server-side applications require different default scopes than client-side SPAs.

Scope resolution and merging

At request time, scopes are automatically merged, deduplicated, and sorted when combining base configuration with method-level options:

// Base configuration with Record scope
const serverClient = new ServerClient({
  authorizationParams: {
    scope: {
      'https://api.example.com': 'read:data'
    }
  }
});

// Method call adds additional scope
await serverClient.startInteractiveLogin({
  authorizationParams: {
    audience: 'https://api.example.com',
    scope: 'write:data'
  }
});

// Final scope: 'read:data write:data' (merged and sorted)
// Base configuration with string scope
const serverClient = new ServerClient({
  authorizationParams: {
    scope: 'email offline_access openid profile read:data'
  }
});

// Method call adds additional scope
await serverClient.startInteractiveLogin({
  authorizationParams: {
    scope: 'write:data'
  }
});

// Final scope: 'email offline_access openid profile read:data write:data' (merged, deduplicated, sorted)

Important

For login operations (startInteractiveLogin, loginBackchannel), the openid scope is always guaranteed to be present, even if not explicitly requested. This ensures ID tokens are always returned.

Scope behavior summary

Scenario Configuration Result
No scope provided {} 'email offline_access openid profile'
String scope scope: 'read:data' 'read:data' (as-is)
Record with audience match scope: {'api': 'read:api'} + audience: 'api' 'read:api' (as-is)
Record without audience match scope: {'api1': 'x'} + audience: 'api2' {'api1': 'x', 'api2': 'email offline_access openid profile'}
String + requested scope Base: 'read:data' + Requested: 'write:data' 'read:data write:data' (merged)
Record + requested scope Base: {'api': 'read:api'} + Requested: 'write:api' for api 'read:api write:api' (merged)

@jacobovidal jacobovidal marked this pull request as draft February 6, 2026 13:28
@jacobovidal jacobovidal marked this pull request as ready for review February 11, 2026 15:33
Comment thread packages/auth0-server-js/src/utils.ts Outdated
Comment on lines +159 to +165
const targetAudience = requestedAudience || configuredAudience || DEFAULT_AUDIENCE;

// Get base scope for the target audience
const baseScope = getScopeForAudience(configuredScope, targetAudience);

// Merge base scope with requested scope
const resolvedScope = mergeScopes(baseScope, requestedScope);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to call resolveTokenScopes here to make it clear it;s doing the same, and additionally ensures it has openid.

Suggested change
const targetAudience = requestedAudience || configuredAudience || DEFAULT_AUDIENCE;
// Get base scope for the target audience
const baseScope = getScopeForAudience(configuredScope, targetAudience);
// Merge base scope with requested scope
const resolvedScope = mergeScopes(baseScope, requestedScope);
const resolvedScope = resolveTokenScopes(configuredScope, configuredAudience, requestedAudience, requestedScope);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants