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 }),
}
}