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
12 changes: 12 additions & 0 deletions .changeset/configurable-oauth-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@uppy/companion': minor
---

Allow per-provider OAuth `scope` and `customParams` to be configured via `providerOptions`.

Until now, OAuth scopes were hardcoded in `@uppy/companion`'s grant config (e.g. `Files.Read.All` for OneDrive, `drive.readonly` for Google Drive, `email user_photos` for Facebook), forcing operators with stricter consent-screen requirements to fork or patch `node_modules`. This change introduces two optional fields on each per-provider entry of `providerOptions`:

- `scope: string[]` — replaces the default OAuth scope for the provider. Applied after `getExtraGrantConfig()`, so it also overrides scopes set by provider classes.
- `customParams: Record<string, string>` — shallow-merged onto the provider's default `custom_params` (e.g. Google's `access_type: 'offline'`).

Defaults are unchanged. Operators are responsible for verifying that any narrowed scope still satisfies the listing/download paths their integration uses (e.g. Google Drive's picker requires `drive.readonly`; narrowing to `drive.file` returns an empty listing).
10 changes: 10 additions & 0 deletions packages/@uppy/companion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ const options = {
drive: {
key: 'GOOGLE_KEY',
secret: 'GOOGLE_SECRET',
// Optional: override the OAuth scope requested at consent time. When
// omitted, Companion uses the default scope for the provider. Operators
// can supply a narrower scope (e.g. `drive.file` for Google Drive,
// `Files.Read` for OneDrive) to satisfy least-privilege requirements,
// accepting any provider-side functional trade-offs.
scope: ['https://www.googleapis.com/auth/drive.file'],
// Optional: shallow-merged onto the provider's default `custom_params`.
// Useful for adding things like `login_hint` without losing
// `access_type: 'offline'`.
customParams: { login_hint: 'user@example.com' },
},
},
server: {
Expand Down
41 changes: 41 additions & 0 deletions packages/@uppy/companion/src/server/provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ export function addProviderOptions(
key?: string | undefined
secret?: string | undefined
credentialsURL?: string | undefined
scope?: string[]
customParams?: Record<string, string>
}
>
},
Expand Down Expand Up @@ -217,6 +219,45 @@ export function addProviderOptions(
Object.assign(grantProviderConfig, provider.getExtraGrantConfig())
}

// Allow operators to narrow or replace OAuth scopes and append custom
// params per provider. Runs after `getExtraGrantConfig()` so values
// supplied here win against provider-class defaults.
if (Array.isArray(providerOption.scope)) {
grantProviderConfig['scope'] = providerOption.scope
}
if (
providerOption.customParams &&
typeof providerOption.customParams === 'object' &&
!Array.isArray(providerOption.customParams)
) {
// Drop non-string values defensively — TS types say
// `Record<string, string>` but JS callers can still pass numbers /
// booleans, which Grant forwards verbatim to OAuth providers and
// produces inconsistent downstream behaviour.
const sanitisedCustomParams: Record<string, string> = {}
for (const [key, value] of Object.entries(
providerOption.customParams,
)) {
if (typeof value === 'string') {
sanitisedCustomParams[key] = value
}
}
// Guard against a malformed existing `custom_params` (e.g. a provider
// class returning a non-object from `getExtraGrantConfig`) — spreading
// a string would expand to character-indexed keys.
const existingCustomParams = grantProviderConfig['custom_params']
const baseCustomParams =
existingCustomParams &&
typeof existingCustomParams === 'object' &&
!Array.isArray(existingCustomParams)
? (existingCustomParams as Record<string, string>)
: {}
grantProviderConfig['custom_params'] = {
...baseCustomParams,
...sanitisedCustomParams,
}
}

// override grant.js redirect uri with companion's custom redirect url
const isExternal = !!server.implicitPath
const redirectPath = getRedirectPath(providerName)
Expand Down
103 changes: 103 additions & 0 deletions packages/@uppy/companion/test/provider-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,109 @@ describe('Test Provider options', () => {
})
})

describe('Test Provider scope and customParams overrides', () => {
beforeEach(() => {
setDefaultEnv()
grantConfig = createGrantConfig()
companionOptions = getCompanionOptions()
})

function getDriveOption() {
const drive = companionOptions.providerOptions?.['drive']
if (drive == null) {
throw new Error('Expected companionOptions.providerOptions["drive"]')
}
return drive as typeof drive & {
scope?: unknown
customParams?: unknown
}
}

test('replaces the default OAuth scope (googledrive) when an array is supplied', () => {
getDriveOption().scope = [
'https://www.googleapis.com/auth/drive.file',
]

providerManager.addProviderOptions(
getAddProviderOptionsArgs(companionOptions),
grantConfig,
getOauthProvider,
)

expect(
requireGrantProviderConfig(grantConfig, 'googledrive')['scope'],
).toEqual(['https://www.googleapis.com/auth/drive.file'])
})

test('merges customParams over the provider defaults (googledrive)', () => {
getDriveOption().customParams = {
login_hint: 'user@example.com',
// Override one default field; the others should remain.
prompt: 'select_account',
}

providerManager.addProviderOptions(
getAddProviderOptionsArgs(companionOptions),
grantConfig,
getOauthProvider,
)

expect(
requireGrantProviderConfig(grantConfig, 'googledrive')['custom_params'],
).toEqual({
access_type: 'offline',
prompt: 'select_account',
login_hint: 'user@example.com',
})
})

test('ignores non-array scope and non-object customParams', () => {
const drive = getDriveOption()
drive.scope = 'drive.file'
drive.customParams = 'not-an-object'

providerManager.addProviderOptions(
getAddProviderOptionsArgs(companionOptions),
grantConfig,
getOauthProvider,
)

expect(
requireGrantProviderConfig(grantConfig, 'googledrive')['scope'],
).toEqual(['https://www.googleapis.com/auth/drive.readonly'])
expect(
requireGrantProviderConfig(grantConfig, 'googledrive')['custom_params'],
).toEqual({
access_type: 'offline',
prompt: 'consent',
})
})

test('drops non-string customParams values (TS type is Record<string, string>)', () => {
getDriveOption().customParams = {
login_hint: 'user@example.com',
// Non-string values should be dropped, not forwarded to Grant.
max_age: 3600,
include_granted_scopes: true,
nested: { foo: 'bar' },
}

providerManager.addProviderOptions(
getAddProviderOptionsArgs(companionOptions),
grantConfig,
getOauthProvider,
)

expect(
requireGrantProviderConfig(grantConfig, 'googledrive')['custom_params'],
).toEqual({
access_type: 'offline',
prompt: 'consent',
login_hint: 'user@example.com',
})
})
})

describe('Test Custom Provider options', () => {
test('adds custom provider options', () => {
const providers = providerManager.getDefaultProviders()
Expand Down