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
3 changes: 3 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ services:
VUE_APP_POSTHOG_API_KEY: ${VUE_APP_POSTHOG_API_KEY:-}
VUE_APP_GOOGLE_CLIENT_ID: ${CLIENT_ID:-}
VUE_APP_MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
VUE_APP_OIDC_PROVIDER_NAME: ${OIDC_PROVIDER_NAME:-}
VUE_APP_OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
VUE_APP_OIDC_AUTHORIZATION_ENDPOINT: ${OIDC_ISSUER_URL:-}/authorize
volumes:
- frontend_dist:/app/dist
command:
Expand Down
6 changes: 6 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ COPY . .
ARG VUE_APP_POSTHOG_API_KEY=""
ARG VUE_APP_GOOGLE_CLIENT_ID=""
ARG VUE_APP_MICROSOFT_CLIENT_ID=""
ARG VUE_APP_OIDC_CLIENT_ID=""
ARG VUE_APP_OIDC_AUTHORIZATION_ENDPOINT=""
ARG VUE_APP_OIDC_PROVIDER_NAME=""

ENV VUE_APP_POSTHOG_API_KEY=$VUE_APP_POSTHOG_API_KEY
ENV VUE_APP_GOOGLE_CLIENT_ID=$VUE_APP_GOOGLE_CLIENT_ID
ENV VUE_APP_MICROSOFT_CLIENT_ID=$VUE_APP_MICROSOFT_CLIENT_ID
ENV VUE_APP_OIDC_CLIENT_ID=$VUE_APP_OIDC_CLIENT_ID
ENV VUE_APP_OIDC_AUTHORIZATION_ENDPOINT=$VUE_APP_OIDC_AUTHORIZATION_ENDPOINT
ENV VUE_APP_OIDC_PROVIDER_NAME=$VUE_APP_OIDC_PROVIDER_NAME

# Build the app and move to /build so it's not hidden by volume mount
RUN npm run build && mv dist /build
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ import {
isPhone,
post,
signInGoogle,
signInOidc,
signInOutlook,
isPremiumUser,
} from "@/utils"
Expand Down Expand Up @@ -391,6 +392,10 @@ export default {
state,
selectAccount: true,
})
} else if (calendarType === calendarTypes.OIDC) {
signInOidc({
state,
})
}
}
},
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/components/SignInDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@
<v-spacer />
</div>
</v-btn>
<v-btn
v-if="oidcEnabled"
block
@click="signIn(calendarTypes.OIDC)"
class="tw-bg-white"
>
<div class="tw-flex tw-w-full tw-items-center tw-gap-2">
<v-icon class="tw-flex-initial" size="20"
>mdi-shield-account-outline</v-icon
>
<v-spacer />
Continue with {{ oidcProviderName }}
<v-spacer />
</div>
</v-btn>

<div class="tw-my-2 tw-flex tw-items-center tw-gap-3">
<v-divider />
Expand Down Expand Up @@ -206,6 +221,17 @@ export default {
props: {
value: { type: Boolean, required: true },
},
computed: {
oidcEnabled() {
return !!(
process.env.VUE_APP_OIDC_CLIENT_ID &&
process.env.VUE_APP_OIDC_AUTHORIZATION_ENDPOINT
)
},
oidcProviderName() {
return process.env.VUE_APP_OIDC_PROVIDER_NAME || "SSO"
},
},
data() {
return {
calendarTypes,
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ export const calendarTypes = Object.freeze({
GOOGLE: "google",
APPLE: "apple",
OUTLOOK: "outlook",
ICS: "ics"
ICS: "ics",
OIDC: "oidc",
})

export const upgradeDialogTypes = Object.freeze({
Expand Down Expand Up @@ -183,4 +184,4 @@ export const guestUserId = "000000000000000000000000"

export const numFreeEvents = 3

export const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/
export const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/
27 changes: 27 additions & 0 deletions frontend/src/utils/sign_in_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,30 @@ export const signInOutlook = ({
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&response_mode=query&scope=${scope}${stateString}`
window.location.href = url
}

/**
* Redirects the user to the configured OIDC provider's authorization
* endpoint to sign in / create an account. This is authentication-only.
*
* Requires VUE_APP_OIDC_CLIENT_ID and VUE_APP_OIDC_AUTHORIZATION_ENDPOINT to
* be set at frontend build time.
*/
export const signInOidc = ({ state = {} } = {}) => {
const clientId = process.env.VUE_APP_OIDC_CLIENT_ID
const authorizationEndpoint = process.env.VUE_APP_OIDC_AUTHORIZATION_ENDPOINT
const redirectUri = encodeURIComponent(`${window.location.origin}/auth`)

// We put scope on `state` because not every OIDC provider echoes `scope`
// back on the callback. Auth.vue reads `scope ?? state.scope`.
const scope = "openid email profile"

if (!state) state = {}
state.calendarType = calendarTypes.OIDC
state.scope = scope
const stateString = `&state=${encodeURIComponent(JSON.stringify(state))}`

const url = `${authorizationEndpoint}?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=${encodeURIComponent(
scope,
)}${stateString}`
window.location.href = url
}
31 changes: 30 additions & 1 deletion frontend/src/views/SignIn.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@
<v-spacer />
</div>
</v-btn>
<v-btn
v-if="oidcEnabled"
block
@click="signIn(calendarTypes.OIDC)"
class="tw-bg-white"
>
<div class="tw-flex tw-w-full tw-items-center tw-gap-2">
<v-icon class="tw-flex-initial" size="20"
>mdi-shield-account-outline</v-icon
>
<v-spacer />
{{ isSignUp ? "Sign up with" : "Continue with" }}
{{ oidcProviderName }}
<v-spacer />
</div>
</v-btn>

<div class="tw-my-2 tw-flex tw-items-center tw-gap-3">
<v-divider />
Expand Down Expand Up @@ -251,7 +267,7 @@

<script>
import { authTypes, calendarTypes } from "@/constants"
import { post, signInGoogle, signInOutlook } from "@/utils"
import { post, signInGoogle, signInOutlook, signInOidc } from "@/utils"
import { mapMutations } from "vuex"
import Logo from "@/components/Logo.vue"

Expand All @@ -276,6 +292,17 @@ export default {
upgradeRedirect() {
return this.$route.query.redirect === "upgrade"
},
// Only show the OIDC button if the frontend was built with an OIDC
// client id/authorization endpoint configured
oidcEnabled() {
return !!(
process.env.VUE_APP_OIDC_CLIENT_ID &&
process.env.VUE_APP_OIDC_AUTHORIZATION_ENDPOINT
)
},
oidcProviderName() {
return process.env.VUE_APP_OIDC_PROVIDER_NAME || "SSO"
},
},

data() {
Expand Down Expand Up @@ -307,6 +334,8 @@ export default {
signInGoogle({ state, selectAccount: true })
} else if (provider === calendarTypes.OUTLOOK) {
signInOutlook({ state, selectAccount: true })
} else if (provider === calendarTypes.OIDC) {
signInOidc({ state })
}
},
validateEmail() {
Expand Down
8 changes: 7 additions & 1 deletion server/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,10 @@ SESSION_SECRET=?
# CORS
# - Comma-separated list of allowed origins
# - For local development, set to: http://localhost:8080
CORS_ORIGINS=?
CORS_ORIGINS=?

# Custom OIDC provider for login, optional
OIDC_PROVIDER_NAME=
OIDC_CLIENT_ID=
OIDC_ISSUER_URL=
OIDC_CLIENT_SECRET=
1 change: 1 addition & 0 deletions server/models/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const (
GoogleCalendarType CalendarType = "google"
OutlookCalendarType CalendarType = "outlook"
ICSCalendarType CalendarType = "ics"
OidcCalendarType CalendarType = "oidc"
)

// OAuth2CalendarAuth contains necessary auth info for the user's google calendar account
Expand Down
131 changes: 84 additions & 47 deletions server/routes/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,14 @@ func signInMobile(c *gin.Context) {

// Helper function to sign user in with the given parameters from the google oauth route
func signInHelper(c *gin.Context, token auth.TokenResponse, tokenOrigin models.TokenOriginType, calendarType models.CalendarType, timezoneOffset int) (models.User, error) {
// OIDC is authentication-only: we don't get calendar access from it, so all of the CalendarAccounts/subcalendar bookkeeping
// below is skipped for it.
isOidc := calendarType == models.OidcCalendarType

// Get access token expire time
accessTokenExpireDate := utils.GetAccessTokenExpireDate(token.ExpiresIn)

// Construct calendar auth object
// Construct calendar auth object (unused for OIDC)
calendarAuth := models.OAuth2CalendarAuth{
AccessToken: token.AccessToken,
AccessTokenExpireDate: primitive.NewDateTimeFromTime(accessTokenExpireDate),
Expand Down Expand Up @@ -168,6 +172,28 @@ func signInHelper(c *gin.Context, token auth.TokenResponse, tokenOrigin models.T
firstName = userInfo.FirstName
lastName = userInfo.LastName
picture = ""
} else if isOidc {
// Verify the ID token returned by the OIDC provider.
// As with Google above, we never trust claims from a token we
// haven't verified ourselves, before using any of its claims.
claims, err := auth.VerifyOidcIdToken(token.IdToken)
if err != nil {
logger.StdErr.Printf("Failed to verify OIDC ID token: %v", err)
return models.User{}, err
}
email = claims.Email
firstName = claims.GivenName
lastName = claims.FamilyName
if firstName == "" && claims.Name != "" {
// Fall back to splitting the "name" claim if the provider didn't
// send given_name/family_name
parts := strings.SplitN(claims.Name, " ", 2)
firstName = parts[0]
if len(parts) > 1 {
lastName = parts[1]
}
}
picture = claims.Picture
}
email = utils.NormalizeEmail(email)

Expand All @@ -186,29 +212,34 @@ func signInHelper(c *gin.Context, token auth.TokenResponse, tokenOrigin models.T
TokenOrigin: tokenOrigin,
}

calendarAccount := models.CalendarAccount{
CalendarType: calendarType,
OAuth2CalendarAuth: &calendarAuth,

Email: email,
Picture: picture,
Enabled: utils.TruePtr(), // Workaround to pass a boolean pointer
}
var calendarAccount models.CalendarAccount
canonicalKey := utils.GetCalendarAccountKey(email, calendarType)
if !isOidc {
calendarAccount = models.CalendarAccount{
CalendarType: calendarType,
OAuth2CalendarAuth: &calendarAuth,

Email: email,
Picture: picture,
Enabled: utils.TruePtr(), // Workaround to pass a boolean pointer
}
}

var userId primitive.ObjectID
existing := db.GetUserByEmail(email)
// If user doesn't exist, create a new user
if existing == nil {
// Fetch subcalendars
subCalendars, err := calendar.GetCalendarProvider(calendarAccount).GetCalendarList()
if err == nil {
calendarAccount.SubCalendars = &subCalendars
}
if !isOidc {
// Fetch subcalendars
subCalendars, err := calendar.GetCalendarProvider(calendarAccount).GetCalendarList()
if err == nil {
calendarAccount.SubCalendars = &subCalendars
}

// Set calendar accounts
userData.CalendarAccounts = map[string]models.CalendarAccount{
canonicalKey: calendarAccount,
// Set calendar accounts
userData.CalendarAccounts = map[string]models.CalendarAccount{
canonicalKey: calendarAccount,
}
}

// Create user
Expand All @@ -230,43 +261,49 @@ func signInHelper(c *gin.Context, token auth.TokenResponse, tokenOrigin models.T
userData.LastName = ""
}

legacyKey := utils.ActualCalendarAccountMapKey(user, email, calendarType)
if isOidc {
// OIDC is authentication-only - leave any existing calendar
// accounts untouched, just refresh profile info below.
userData.CalendarAccounts = user.CalendarAccounts
} else {
legacyKey := utils.ActualCalendarAccountMapKey(user, email, calendarType)

var oldSubCalendars *map[string]models.SubCalendar
if legacyKey != "" {
if oldAcc, ok := user.CalendarAccounts[legacyKey]; ok && oldAcc.SubCalendars != nil {
oldSubCalendars = oldAcc.SubCalendars
}
} else if user.CalendarAccounts != nil {
if existingAcc, ok := user.CalendarAccounts[canonicalKey]; ok && existingAcc.SubCalendars != nil {
oldSubCalendars = existingAcc.SubCalendars
}
}

var oldSubCalendars *map[string]models.SubCalendar
if legacyKey != "" {
if oldAcc, ok := user.CalendarAccounts[legacyKey]; ok && oldAcc.SubCalendars != nil {
oldSubCalendars = oldAcc.SubCalendars
var calAccounts map[string]models.CalendarAccount
if user.CalendarAccounts == nil {
calAccounts = make(map[string]models.CalendarAccount)
} else {
calAccounts = make(map[string]models.CalendarAccount, len(user.CalendarAccounts))
for k, v := range user.CalendarAccounts {
calAccounts[k] = v
}
}
} else if user.CalendarAccounts != nil {
if existingAcc, ok := user.CalendarAccounts[canonicalKey]; ok && existingAcc.SubCalendars != nil {
oldSubCalendars = existingAcc.SubCalendars
if legacyKey != "" && legacyKey != canonicalKey {
delete(calAccounts, legacyKey)
}
}

var calAccounts map[string]models.CalendarAccount
if user.CalendarAccounts == nil {
calAccounts = make(map[string]models.CalendarAccount)
} else {
calAccounts = make(map[string]models.CalendarAccount, len(user.CalendarAccounts))
for k, v := range user.CalendarAccounts {
calAccounts[k] = v
if oldSubCalendars != nil {
calendarAccount.SubCalendars = oldSubCalendars
} else {
subCalendars, err := calendar.GetCalendarProvider(calendarAccount).GetCalendarList()
if err == nil {
calendarAccount.SubCalendars = &subCalendars
}
}
}
if legacyKey != "" && legacyKey != canonicalKey {
delete(calAccounts, legacyKey)
}

if oldSubCalendars != nil {
calendarAccount.SubCalendars = oldSubCalendars
} else {
subCalendars, err := calendar.GetCalendarProvider(calendarAccount).GetCalendarList()
if err == nil {
calendarAccount.SubCalendars = &subCalendars
}
calAccounts[canonicalKey] = calendarAccount
userData.CalendarAccounts = calAccounts
}

calAccounts[canonicalKey] = calendarAccount
userData.CalendarAccounts = calAccounts
userData.Email = email

// Update user if exists
Expand Down
Loading