Skip to content
Draft
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
29 changes: 26 additions & 3 deletions docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ source:
The ZEP adapter is only supported as a source.

## Outlook Adapter Setup

The Outlook calendar is synchronized via Microsoft Graph API. You will need to
[register an application on Azure](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app).
The application needs the following permissions:

* `Calendar.ReadWrite`
* `Calendars.ReadWrite`

The `User.read` permission should be assigned by default. To assign the `Calendar.ReadWrite` permission, click on "API Permissions" and add the permission to the "Microsoft Graph API".
The `User.Read` permission should be assigned by default. To assign the `Calendars.ReadWrite` permission, click on "API Permissions" and add the delegated permission to the "Microsoft Graph API".

You also need to setup a platform specific configuration. This can be done in the "Authentication" menu. Add a "mobile and desktop application" platform configuration and add `http://localhost/redirect` as a valid redirect uri.

Expand All @@ -40,13 +41,35 @@ source:
adapter:
type: "outlook_http"
calendar: "[base64-format string here]"
config:
oAuth:
tenantId: "[UUID-format string here]"
clientId: "[UUID-format string here]"
```

To get your calendar ID, use the [Microsoft Graph Explorer](https://developer.microsoft.com/en-us/graph/graph-explorer) and query `GET https://graph.microsoft.com/v1.0/me/calendar`.

### Shared Mailboxes and delegated calendars

By default, the adapter accesses `/me/calendars/{calendarID}`. To target a Microsoft 365 Shared Mailbox or another delegated user's calendar, set `config.user` to that mailbox's user principal name (UPN) or Entra object ID:

```yaml
source:
adapter:
type: "outlook_http"
calendar: "[shared-calendar-id]"
config:
user: "shared-mailbox@example.com"
oAuth:
tenantId: "[UUID-format string here]"
clientId: "[UUID-format string here]"
```

CalendarSync still authenticates an interactive user, not the Shared Mailbox itself. That user must have the appropriate Exchange Online mailbox or calendar delegation for the target mailbox. Microsoft Graph's delegated `Calendars.Read.Shared` permission is sufficient for read-only access. CalendarSync requests `Calendars.ReadWrite.Shared` in addition to `Calendars.ReadWrite` when `config.user` is set because the Outlook adapter can also be used as a sink. The Graph permission does not grant access unless the mailbox delegation is also configured. See Microsoft's documentation for [accessing shared or delegated Outlook calendars](https://learn.microsoft.com/en-us/graph/outlook-get-shared-events-calendars) and [creating events in shared or delegated calendars](https://learn.microsoft.com/en-us/graph/outlook-create-event-in-shared-delegated-calendar). The same `config.user` mechanism can target a delegated normal user mailbox.

Microsoft Graph does not allow delegates to create events with open extensions in Shared Mailbox calendars. CalendarSync therefore stores its synchronization metadata in a single-value legacy extended property whenever `config.user` is set. Existing `/me` configurations continue to use open extensions without changing their stored metadata. See Microsoft's [open-extension limitations](https://learn.microsoft.com/en-us/graph/extensibility-overview#comparison-of-extension-types).

If `config.user` is omitted or empty, the existing `/me` behavior and permissions remain unchanged. When enabling it for an existing configuration, remove the affected CalendarSync authentication entry using the normal auth-storage workflow and authenticate again so the new shared-calendar permission can be granted. Do not edit encrypted authentication storage manually.


## Google Adapter Setup

Expand Down
3 changes: 3 additions & 0 deletions example.sync.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ source:
adapter:
type: "outlook_http"
calendar: "[base64-format string here]"
# Optional: target a shared/delegated mailbox instead of /me.
# config:
# user: "shared-mailbox@example.com"
oAuth:
clientId: "[UUID-format string here]"
tenantId: "[UUID-format string here]"
Expand Down
35 changes: 35 additions & 0 deletions internal/adapter/adapter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package adapter

import (
"context"
"testing"

"github.com/charmbracelet/log"
"github.com/stretchr/testify/require"

"github.com/inovex/CalendarSync/internal/config"
)

func TestNewSourceAdapterConfiguresBeforeOAuth(t *testing.T) {
adapterConfig := config.NewAdapterConfig(config.Adapter{
Type: string(OutlookHttpCalendarType),
Calendar: "calendar-id",
Config: config.CustomMap{"user": 42},
})

_, err := NewSourceAdapterFromConfig(context.Background(), 0, false, adapterConfig, nil, log.Default())

require.EqualError(t, err, "Outlook adapter config 'user' must be a string")
}

func TestNewSinkAdapterConfiguresBeforeOAuth(t *testing.T) {
adapterConfig := config.NewAdapterConfig(config.Adapter{
Type: string(OutlookHttpCalendarType),
Calendar: "calendar-id",
Config: config.CustomMap{"user": 42},
})

_, err := NewSinkAdapterFromConfig(context.Background(), 0, false, adapterConfig, nil, log.Default())

require.EqualError(t, err, "Outlook adapter config 'user' must be a string")
}
34 changes: 28 additions & 6 deletions internal/adapter/outlook_http/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type OutlookCalendarClient interface {
type CalendarAPI struct {
outlookClient OutlookCalendarClient
calendarID string
user string

oAuthConfig *oauth2.Config
authenticated bool
Expand All @@ -44,6 +45,7 @@ type CalendarAPI struct {

// Assert that the expected interfaces are implemented
var _ port.Configurable = &CalendarAPI{}
var _ port.ConfigSetter = &CalendarAPI{}
var _ port.LogSetter = &CalendarAPI{}
var _ port.CalendarIDSetter = &CalendarAPI{}
var _ port.OAuth2Adapter = &CalendarAPI{}
Expand All @@ -56,6 +58,21 @@ func (c *CalendarAPI) SetCalendarID(calendarID string) error {
return nil
}

func (c *CalendarAPI) SetConfig(config map[string]interface{}) error {
user, configured := config["user"]
if !configured {
c.user = ""
return nil
}

configuredUser, ok := user.(string)
if !ok {
return fmt.Errorf("%s adapter config 'user' must be a string", c.Name())
}
c.user = configuredUser
return nil
}

func (c *CalendarAPI) SetupOauth2(ctx context.Context, credentials auth.Credentials, storage auth.Storage, bindPort uint) error {
// Outlook Adapter does not need the clientKey
switch {
Expand All @@ -71,10 +88,16 @@ func (c *CalendarAPI) SetupOauth2(ctx context.Context, credentials auth.Credenti
AuthStyle: oauth2.AuthStyleInParams,
}

scopes := []string{"Calendars.ReadWrite"}
if c.user != "" {
scopes = append(scopes, "Calendars.ReadWrite.Shared")
}
scopes = append(scopes, "offline_access")

oAuthConfig := oauth2.Config{
ClientID: credentials.Client.Id,
Endpoint: endpoint,
Scopes: []string{"Calendars.ReadWrite", "offline_access"}, // You need to request offline_access in order to retrieve a refresh token
Scopes: scopes, // You need to request offline_access in order to retrieve a refresh token
}

oAuthListener, err := auth.NewOAuthHandler(oAuthConfig, bindPort)
Expand All @@ -84,7 +107,8 @@ func (c *CalendarAPI) SetupOauth2(ctx context.Context, credentials auth.Credenti

c.oAuthHandler = oAuthListener
c.storage = storage
c.oAuthConfig = &oAuthConfig
c.oAuthConfig = c.oAuthHandler.Configuration()
c.oAuthUrl = c.oAuthConfig.AuthCodeURL("state", oauth2.AccessTypeOffline)

storedAuth, err := c.storage.ReadCalendarAuth(c.calendarID)
if err != nil {
Expand Down Expand Up @@ -166,10 +190,8 @@ func (c *CalendarAPI) SetupOauth2(ctx context.Context, credentials auth.Credenti
return nil
}

func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config map[string]interface{}) error {
func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, _ map[string]interface{}) error {
if !c.authenticated {
c.oAuthUrl = c.oAuthHandler.Configuration().AuthCodeURL("state", oauth2.AccessTypeOffline)

if openBrowser {
c.logger.Infof("opening browser window for authentication of %s\n", c.Name())
err := browser.OpenURL(c.oAuthUrl)
Expand Down Expand Up @@ -202,7 +224,7 @@ func (c *CalendarAPI) Initialize(ctx context.Context, openBrowser bool, config m

client := c.oAuthConfig.Client(ctx, c.oAuthToken)

c.outlookClient = &OutlookClient{Client: client, CalendarID: c.calendarID}
c.outlookClient = &OutlookClient{Client: client, CalendarID: c.calendarID, User: c.user}
return nil
}

Expand Down
117 changes: 117 additions & 0 deletions internal/adapter/outlook_http/adapter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package outlook_http

import (
"context"
"net/url"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/inovex/CalendarSync/internal/auth"
"github.com/inovex/CalendarSync/internal/config"
)

type memoryStorage struct{}

func (memoryStorage) WriteCalendarAuth(auth.CalendarAuth) (bool, error) {
return true, nil
}

func (memoryStorage) ReadCalendarAuth(string) (*auth.CalendarAuth, error) {
return nil, nil
}

func (memoryStorage) RemoveCalendarAuth(string) error {
return nil
}

func (memoryStorage) Setup(config.AuthStorage, string) error {
return nil
}

func TestCalendarAPISetConfig(t *testing.T) {
tests := []struct {
name string
config map[string]interface{}
expectedUser string
expectedError string
}{
{
name: "absent user",
},
{
name: "empty user",
config: map[string]interface{}{"user": ""},
},
{
name: "shared mailbox",
config: map[string]interface{}{"user": "shared-mailbox@example.com"},
expectedUser: "shared-mailbox@example.com",
},
{
name: "non-string user",
config: map[string]interface{}{"user": 42},
expectedError: "Outlook adapter config 'user' must be a string",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
calendarAPI := &CalendarAPI{}

err := calendarAPI.SetConfig(test.config)

if test.expectedError != "" {
require.EqualError(t, err, test.expectedError)
return
}
require.NoError(t, err)
assert.Equal(t, test.expectedUser, calendarAPI.user)
})
}
}

func TestCalendarAPIAuthorizationURLScopes(t *testing.T) {
tests := []struct {
name string
config map[string]interface{}
expectedScopes []string
}{
{
name: "current user",
expectedScopes: []string{"Calendars.ReadWrite", "offline_access"},
},
{
name: "shared mailbox",
config: map[string]interface{}{"user": "shared-mailbox@example.com"},
expectedScopes: []string{"Calendars.ReadWrite", "Calendars.ReadWrite.Shared", "offline_access"},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
calendarAPI := &CalendarAPI{calendarID: "calendar-id"}
require.NoError(t, calendarAPI.SetConfig(test.config))

err := calendarAPI.SetupOauth2(
context.Background(),
auth.Credentials{
Client: auth.Client{Id: "client-id"},
Tenant: auth.Tenant{Id: "tenant-id"},
},
memoryStorage{},
0,
)
require.NoError(t, err)

authorizationURL, err := url.Parse(calendarAPI.oAuthUrl)
require.NoError(t, err)
urlScopes := strings.Fields(authorizationURL.Query().Get("scope"))

assert.ElementsMatch(t, test.expectedScopes, urlScopes)
assert.ElementsMatch(t, test.expectedScopes, calendarAPI.oAuthConfig.Scopes)
})
}
}
Loading