package handler import ( "html/template" "net/http" "net/url" "os" "strings" "github.com/gin-gonic/gin" "github.com/warmbly/warmbly/internal/app/delegation" "github.com/warmbly/warmbly/internal/app/email" "github.com/warmbly/warmbly/internal/app/poollink" "github.com/warmbly/warmbly/internal/config" ) // callbackPage renders a tiny HTML page that hands the OAuth code + state // back to the opening window via postMessage and then closes itself. // The opener (the SPA) is expected to POST the code/state to // /emails/onboarding/oauth/finish with the user's bearer token. // // Without an opener (the native app's ASWebAuthenticationSession, which has // no popup parent) it instead redirects to the app's warmbly:// scheme; the // session intercepts that navigation and the app calls oauth/finish itself. // // We keep this on the API rather than the SPA so that the provider's // registered redirect_uri stays under our control and survives front-end // reshuffles. var callbackPage = template.Must(template.New("oauth-cb").Parse(` Connecting…
{{.Status}}
{{if .Error}}
{{.Error}}
{{end}}
`)) type callbackData struct { Provider string Code string State string Error string Status string AppOrigin string NoOriginNotice string } // callbackNoOriginNotice is shown instead of handing a code to an opener whose origin is unknown. const callbackNoOriginNotice = "This instance has no dashboard address configured, so the sign-in cannot be handed back. Ask the operator to set APP_URL, then try again." // callbackTargetOrigin is the one origin an authorization code is posted to: // APP_ORIGIN when set, else the origin of APP_URL. Empty delivers nothing. func callbackTargetOrigin() string { if v := strings.TrimSpace(os.Getenv("APP_ORIGIN")); v != "" { return v } u, err := url.Parse(config.AppBaseURL()) if err != nil || u.Scheme == "" || u.Host == "" { return "" } return u.Scheme + "://" + u.Host } func (h *Handler) EmailOAuthCallbackGmail(c *gin.Context) { h.renderOAuthCallback(c, "gmail") } func (h *Handler) EmailOAuthCallbackOutlook(c *gin.Context) { h.renderOAuthCallback(c, "outlook") } func (h *Handler) renderOAuthCallback(c *gin.Context, provider string) { code := c.Query("code") state := c.Query("state") providerErr := c.Query("error") // A brokered consent completes here, only in the browser the consent page bound it to. if h.PoolLinkService != nil && strings.HasPrefix(state, poollink.BrokerStatePrefix) { binding, _ := c.Cookie(brokerCookieName) to, xerr := h.PoolLinkService.CompleteOAuthCallback(c.Request.Context(), provider, code, state, providerErr, binding) if xerr != nil { renderBrokerNotice(c, xerr) return } c.Redirect(http.StatusFound, to) return } // Microsoft reports an admin consent with no code; the admin grant then needs a sign-in, same state. if strings.HasPrefix(state, delegation.MicrosoftStatePrefix) && providerErr == "" && code == "" && strings.EqualFold(c.Query("admin_consent"), "true") && h.DelegationService != nil { if to := h.DelegationService.MicrosoftSigninURL(c.Request.Context(), state); to != "" { c.Redirect(http.StatusFound, to) return } } // An administrator approving single-mailbox sign-in may be anywhere, with no dashboard to hand back to. if state == email.OutlookAdminApprovalState { renderAdminApproval(c, providerErr == "" && strings.EqualFold(c.Query("admin_consent"), "true")) return } data := callbackData{ Provider: provider, Code: code, State: state, Error: providerErr, Status: "Connecting your mailbox… this window will close.", AppOrigin: callbackTargetOrigin(), NoOriginNotice: callbackNoOriginNotice, } if strings.HasPrefix(state, delegation.GoogleStatePrefix) { data.Status = "Signed in. Finishing in Warmbly… this window will close." } if providerErr != "" { data.Status = "Connection cancelled." } else if code == "" || state == "" { data.Error = "missing_code_or_state" data.Status = "Connection cancelled." } // This page is one inline script that hands the code to the opener and // closes. It loads nothing and submits nothing, so the policy says so; // 'unsafe-inline' covers the script that is the page itself. // Cross-Origin-Opener-Policy is relaxed here because talking to the // opener is the whole job, and the message is addressed to one origin. c.Header("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'") c.Header("Cross-Origin-Opener-Policy", "unsafe-none") c.Header("Content-Type", "text/html; charset=utf-8") c.Status(http.StatusOK) _ = callbackPage.Execute(c.Writer, data) } var noticePage = template.Must(template.New("oauth-notice").Parse(` {{.Title}}

{{.Title}}

{{.Body}}

`)) // renderAdminApproval answers the return from an administrator approving single-mailbox Microsoft sign-in. func renderAdminApproval(c *gin.Context, approved bool) { data := struct{ Title, Body string }{ Title: "Approved", Body: "People in your organization can now connect their Microsoft mailboxes to Warmbly. You can close this window.", } if !approved { data.Title = "Not approved" data.Body = "Microsoft did not record the approval. Open the link again and sign in as a Global Administrator, Cloud Application Administrator or Application Administrator." } renderNotice(c, http.StatusOK, data.Title, data.Body) } // renderNotice is a standalone page with a title and one sentence, and nothing to run. func renderNotice(c *gin.Context, status int, title, body string) { c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'") c.Header("Content-Type", "text/html; charset=utf-8") c.Status(status) _ = noticePage.Execute(c.Writer, struct{ Title, Body string }{title, body}) }