Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/microsoft

src/scopes.ts

228 lines · 8.3 KB · TypeScript
export const GRAPH_API_BASE_URL = 'https://graph.microsoft.com/v1.0'
export const GRAPH_HOST = 'graph.microsoft.com'
export const LOGIN_HOST = 'login.microsoftonline.com'
export const AZURE_APP_REGISTRATIONS_URL =
  'https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade'
export const KODY_OAUTH_REDIRECT_URI = 'https://kody.codes/connect/oauth'

export const OPENID_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const

export const PRODUCT_SCOPES = {
  profile: ['User.Read'],
  mailRead: ['Mail.Read'],
  mailWrite: ['Mail.ReadWrite'],
  mailSend: ['Mail.Send'],
  calendarRead: ['Calendars.Read'],
  calendarWrite: ['Calendars.ReadWrite'],
  tasksRead: ['Tasks.Read'],
  tasksWrite: ['Tasks.ReadWrite'],
  filesRead: ['Files.Read'],
  filesWrite: ['Files.ReadWrite'],
  chatRead: ['Chat.Read'],
  chatWrite: ['Chat.ReadWrite'],
  chatSend: ['ChatMessage.Send'],
  teamsRead: ['Team.ReadBasic.All'],
  channelsRead: ['Channel.ReadBasic.All'],
  channelMessagesRead: ['ChannelMessage.Read.All'],
  channelMessagesSend: ['ChannelMessage.Send'],
} as const

export const SMOKE_TEST_SCOPES = [...OPENID_SCOPES, ...PRODUCT_SCOPES.profile]

export const ALL_DELEGATED_SCOPES = [
  ...OPENID_SCOPES,
  ...PRODUCT_SCOPES.profile,
  ...PRODUCT_SCOPES.mailRead,
  ...PRODUCT_SCOPES.mailWrite,
  ...PRODUCT_SCOPES.mailSend,
  ...PRODUCT_SCOPES.calendarRead,
  ...PRODUCT_SCOPES.calendarWrite,
  ...PRODUCT_SCOPES.tasksRead,
  ...PRODUCT_SCOPES.tasksWrite,
  ...PRODUCT_SCOPES.filesRead,
  ...PRODUCT_SCOPES.filesWrite,
  ...PRODUCT_SCOPES.chatRead,
  ...PRODUCT_SCOPES.chatWrite,
  ...PRODUCT_SCOPES.chatSend,
  ...PRODUCT_SCOPES.teamsRead,
  ...PRODUCT_SCOPES.channelsRead,
  ...PRODUCT_SCOPES.channelMessagesRead,
  ...PRODUCT_SCOPES.channelMessagesSend,
]

export type GraphMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'

export type ScopeNeed = {
  product: string
  scopes: string[]
  setupNote: string
}

function pathOf(path: string): string {
  return path.split('?')[0] || path
}

/**
 * Infer the delegated Graph scopes a call typically needs so 403s can name
 * the missing permission and the reconnect step.
 */
export function inferRequiredScopes(method: GraphMethod, path: string): ScopeNeed {
  const p = pathOf(path)
  const write = method !== 'GET'

  if (/\/channels\/[^/]+\/messages/.test(p)) {
    return {
      product: 'Teams channel messages',
      scopes: write ? [...PRODUCT_SCOPES.channelMessagesSend] : [...PRODUCT_SCOPES.channelMessagesRead],
      setupNote: write
        ? 'Add ChannelMessage.Send on the Azure app, then reconnect. Work/school tenants may need admin consent.'
        : 'Add ChannelMessage.Read.All on the Azure app, then reconnect. This delegated permission usually needs admin consent in work/school tenants.',
    }
  }
  if (/\/channels/.test(p)) {
    return {
      product: 'Teams channels',
      scopes: [...PRODUCT_SCOPES.channelsRead],
      setupNote: 'Add Channel.ReadBasic.All on the Azure app, then reconnect.',
    }
  }
  if (/\/chats\/[^/]+\/messages/.test(p)) {
    return {
      product: 'Teams chat messages',
      scopes: write ? [...PRODUCT_SCOPES.chatSend] : [...PRODUCT_SCOPES.chatRead],
      setupNote: write
        ? 'Add ChatMessage.Send or Chat.ReadWrite on the Azure app, then reconnect.'
        : 'Add Chat.Read on the Azure app, then reconnect. Work/school tenants may need admin consent.',
    }
  }
  if (/\/chats/.test(p)) {
    return {
      product: 'Teams chat',
      scopes: write ? [...PRODUCT_SCOPES.chatWrite] : [...PRODUCT_SCOPES.chatRead],
      setupNote: write
        ? 'Add Chat.ReadWrite on the Azure app, then reconnect. Work/school tenants may need admin consent.'
        : 'Add Chat.Read on the Azure app, then reconnect. Work/school tenants may need admin consent.',
    }
  }
  if (/\/joinedTeams|\/teams/.test(p)) {
    return {
      product: 'Teams',
      scopes: [...PRODUCT_SCOPES.teamsRead],
      setupNote:
        'Add Team.ReadBasic.All on the Azure app, then reconnect. Work/school tenants may need admin consent.',
    }
  }
  if (/\/sendMail$/.test(p) || (method === 'POST' && /\/messages\/[^/]+\/send$/.test(p))) {
    return {
      product: 'Outlook mail send',
      scopes: [...PRODUCT_SCOPES.mailSend],
      setupNote: 'Add Mail.Send on the Azure app (API permissions → Microsoft Graph → Delegated), then reconnect.',
    }
  }
  if (/\/mailFolders|(?:^|\/)(me|users\/[^/]+)\/messages/.test(p)) {
    return {
      product: 'Outlook mail',
      scopes: write ? [...PRODUCT_SCOPES.mailWrite] : [...PRODUCT_SCOPES.mailRead],
      setupNote: write
        ? 'Add Mail.ReadWrite (and Mail.Send if sending) on the Azure app, then reconnect.'
        : 'Add Mail.Read on the Azure app, then reconnect.',
    }
  }
  if (/\/todo\//.test(p)) {
    return {
      product: 'Microsoft To Do',
      scopes: write ? [...PRODUCT_SCOPES.tasksWrite] : [...PRODUCT_SCOPES.tasksRead],
      setupNote: write
        ? 'Add Tasks.ReadWrite on the Azure app, then reconnect.'
        : 'Add Tasks.Read on the Azure app, then reconnect.',
    }
  }
  if (/\/calendarView|\/calendars|\/events|\/calendar\b/.test(p)) {
    return {
      product: 'Outlook calendar',
      scopes: write ? [...PRODUCT_SCOPES.calendarWrite] : [...PRODUCT_SCOPES.calendarRead],
      setupNote: write
        ? 'Add Calendars.ReadWrite on the Azure app, then reconnect.'
        : 'Add Calendars.Read on the Azure app, then reconnect.',
    }
  }
  if (/\/drive/.test(p)) {
    return {
      product: 'OneDrive',
      scopes: write ? [...PRODUCT_SCOPES.filesWrite] : [...PRODUCT_SCOPES.filesRead],
      setupNote: write
        ? 'Add Files.ReadWrite on the Azure app, then reconnect.'
        : 'Add Files.Read on the Azure app, then reconnect.',
    }
  }
  if (p === '/me' || p.startsWith('/me/')) {
    return {
      product: 'Microsoft profile',
      scopes: [...PRODUCT_SCOPES.profile],
      setupNote: 'Add User.Read on the Azure app, then reconnect.',
    }
  }
  return {
    product: 'Microsoft Graph',
    scopes: [...PRODUCT_SCOPES.profile],
    setupNote: 'Reconnect the Microsoft OAuth integration with the Graph delegated permission this API documents.',
  }
}

function authorizeUrlForTenant(tenant: string): string {
  return 'https://login.microsoftonline.com/' + encodeURIComponent(tenant) + '/oauth2/v2.0/authorize'
}

function tokenUrlForTenant(tenant: string): string {
  return 'https://login.microsoftonline.com/' + encodeURIComponent(tenant) + '/oauth2/v2.0/token'
}

export function buildMicrosoftConnectUrl(input: {
  provider?: string
  tenant?: string
  scopes?: readonly string[]
} = {}): string {
  const tenant = input.tenant ?? 'common'
  const provider = input.provider ?? 'microsoft'
  const scopes = [...(input.scopes ?? SMOKE_TEST_SCOPES)]
  const url = new URL(KODY_OAUTH_REDIRECT_URI)
  url.searchParams.set('provider', provider)
  url.searchParams.set('authorizeUrl', authorizeUrlForTenant(tenant))
  url.searchParams.set('tokenUrl', tokenUrlForTenant(tenant))
  url.searchParams.set('flow', 'confidential')
  url.searchParams.set('pkce', 'true')
  url.searchParams.set('scopes', scopes.join(' '))
  url.searchParams.set('allowedHosts', GRAPH_HOST)
  url.searchParams.set('apiBaseUrl', GRAPH_API_BASE_URL)
  url.searchParams.set('dashboardUrl', AZURE_APP_REGISTRATIONS_URL)
  url.searchParams.set('extraAuthorizeParams', JSON.stringify({ prompt: 'consent' }))
  return url.toString()
}

export function reconnectUrl(integrationName: string, scopes?: readonly string[]): string {
  if (!scopes || scopes.length === 0) {
    return 'https://kody.codes/connect/oauth?provider=' + encodeURIComponent(integrationName)
  }
  return buildMicrosoftConnectUrl({ provider: integrationName, scopes: [...OPENID_SCOPES, ...scopes] })
}

/**
 * Scope catalog and prefilled /connect/oauth URLs for Microsoft Graph.
 * @example
 * import scopes from 'kody:@kody/microsoft/scopes'
 * const { smokeTestConnectUrl } = scopes()
 */
export default function scopes() {
  return {
    auth: 'oauth',
    notApiKey: true,
    notBotToken: true,
    redirectUri: KODY_OAUTH_REDIRECT_URI,
    requiredHosts: [GRAPH_HOST, LOGIN_HOST],
    smokeTestScopes: [...SMOKE_TEST_SCOPES],
    allDelegatedScopes: [...ALL_DELEGATED_SCOPES],
    productScopes: PRODUCT_SCOPES,
    smokeTestConnectUrl: buildMicrosoftConnectUrl(),
    allProductsConnectUrl: buildMicrosoftConnectUrl({ scopes: ALL_DELEGATED_SCOPES }),
  }
}