package oauth import ( "encoding/json" e "errors" "fmt" "net/http" "net/url" "strings" "extrovert/components" "extrovert/internals/router/errors" "extrovert/templates/pages" ) type MastodonOAuthClient struct { Name string Id *string Secret *string RedirectUri *url.URL } const MASTODON_TOKEN_COOKIE_NAME = "OAUTH-MASTODON" type MastodonOAuthToken struct { InstanceUrl string `json:"instance_url"` Type string `json:"token_type"` Token string `json:"access_token"` Scope string `json:"scope"` CreatedAt int `json:"created_at"` } func NewMastodonOAuthClient(redirect *url.URL) *MastodonOAuthClient { return &MastodonOAuthClient{ Name: "project-extrovert-oauth-client", RedirectUri: redirect, } } func (c *MastodonOAuthClient) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { errors.NewErrMethodNotAllowed("GET", "HEAD").ServeHTTP(w, r) return } i := r.FormValue("instance-url") if i == "" { errors.NewErrMissingParams("instance-url").ServeHTTP(w, r) return } iu, err := url.Parse(i) if err != nil { errors. NewErrBadRequest("\"instance-url\" is not a valid URL. Failed to parse due to %s", err.Error()). ServeHTTP(w, r) return } q := c.RedirectUri.Query() q.Del("code") q.Set("instance-url", iu.String()) c.RedirectUri.RawQuery = q.Encode() code := r.FormValue("code") a := NewMastodonOAuthApp(iu) if err := a.PopulateWithRequestCookies(r); err != nil { // This step is necessary since after the user has been redirected back from Mastodon's // authorization endpoint, the browser will not send cookies. Adding this redirect step // forces it to make another request and send the cookies for the PopulateWithRequestCookies // function. if code != "" { q = c.RedirectUri.Query() q.Set("code", code) c.RedirectUri.RawQuery = q.Encode() err = pages.RedirectPopUp( iu.Hostname()+" returned the OAuth code!", "The Mastodon instance "+iu.Hostname()+" returned the authorization code. "+ "Click the button bellow to continue the log in process.", templ.SafeURL(c.RedirectUri.String()), ).Render(r.Context(), w) if err != nil { errors.NewErrInternal(e.New("Unable to render redirect page"), err).ServeHTTP(w, r) return } w.WriteHeader(http.StatusOK) return } err = a.PopulateWithInstance(c.Name, c.RedirectUri) if err != nil { errors. NewErrInternal(e.New("Unable to create application"), err). ServeHTTP(w, r) return } ck, err := a.ToCookie() if err != nil { errors. NewErrInternal(e.New("Unable to make application cookie"), err). ServeHTTP(w, r) return } iu.Path = "/oauth/authorize" q = iu.Query() q.Set("client_id", a.ClientId) q.Set("redirect_uri", c.RedirectUri.String()) q.Set("response_type", "code") q.Set("scope", "read write") q.Set("state", "state") q.Set("code_challenge", "challenge") q.Set("code_challenge_method", "plain") iu.RawQuery = q.Encode() http.SetCookie(w, ck) err = pages.RedirectPopUp( "Create application to use in "+iu.Hostname()+"!", "An OAuth application was created in "+iu.Hostname()+" for you to use. "+ "Click the button bellow to continue the log in process.", templ.SafeURL(iu.String()), ).Render(r.Context(), w) if err != nil { errors.NewErrInternal(e.New("Unable to render redirect page"), err).ServeHTTP(w, r) return } w.WriteHeader(http.StatusOK) return } if code == "" { errors.NewErrMissingParams("code").ServeHTTP(w, r) return } iu.Path = "/oauth/token" q = iu.Query() q.Set("client_id", a.ClientId) q.Set("client_secret", a.ClientSecret) q.Set("code", code) q.Set("redirect_uri", c.RedirectUri.String()) q.Set("scope", "read write") q.Set("grant_type", "authorization_code") q.Set("code_verifier", "challenge") q.Set("challenge_method", "plain") iu.RawQuery = q.Encode() res, err := http.Post(iu.String(), "application/x-www-form-urlencoded", bytes.NewBuffer([]byte(""))) if err != nil { errors.NewErrInternal(e.New("Error while trying to request token"), err).ServeHTTP(w, r) return } body, err := io.ReadAll(res.Body) if err != nil { errors. NewErrInternal(e.New("Error while trying to read token response body"), err). ServeHTTP(w, r) return } if res.StatusCode != 200 { errors.NewErrInternal(fmt.Errorf( "Unable to retrieve token, non-200 response from platform. \nStatus: %v\nBody: %s", res.StatusCode, string(body)), ).ServeHTTP(w, r) return } var t MastodonOAuthToken err = json.Unmarshal(body, &t) if err != nil { errors. NewErrInternal(e.New("Error while trying to validate token response body"), err). ServeHTTP(w, r) return } iu.Path = "" iu.RawQuery = "" t.InstanceUrl = iu.String() cv, err := json.Marshal(t) if err != nil { errors. NewErrInternal(e.New("Error while trying to validate token response body"), err). ServeHTTP(w, r) return } http.SetCookie(w, &http.Cookie{ Name: "__Host-" + MASTODON_TOKEN_COOKIE_NAME, Value: url.PathEscape(string(cv)), SameSite: http.SameSiteStrictMode, Path: "/", Secure: true, HttpOnly: true, }) err = pages.RedirectPopUp( "Logged into "+iu.Hostname()+"!", "Your "+iu.Hostname()+" account was successfully logged into project-extrovert. "+ "Click the button below to return to the Homepage.", templ.SafeURL("/"), ).Render(r.Context(), w) if err != nil { errors.NewErrInternal(e.New("Unable to render redirect page"), err).ServeHTTP(w, r) return } w.WriteHeader(http.StatusOK) } type MastodonOAuthApp struct { InstanceUrl string `json:"instance_url"` ClientId string `json:"client_id"` ClientSecret string `json:"client_secret"` } func NewMastodonOAuthApp(instance *url.URL) MastodonOAuthApp { return MastodonOAuthApp{InstanceUrl: instance.String()} } func (a *MastodonOAuthApp) PopulateWithInstance(name string, redirect *url.URL) error { i, err := url.Parse(a.InstanceUrl) if err != nil { return err } i.Path = "/api/v1/apps" q := i.Query() q.Set("client_name", name) q.Set("redirect_uris", redirect.String()) q.Set("scopes", "read write") i.RawQuery = q.Encode() req, err := http.Post(i.String(), "application/x-www-form-urlencoded", bytes.NewBuffer([]byte(""))) if err != nil { return err } body, err := io.ReadAll(req.Body) if err != nil { return err } else if req.StatusCode != http.StatusOK { return fmt.Errorf( "Unable to create application, instance %s returned a non-200 status code.\nStatus: %v\nBody: %s", i.Hostname(), req.StatusCode, string(body), ) } var ra MastodonOAuthApp err = json.Unmarshal(body, &ra) if err != nil { return err } a.ClientId = ra.ClientId a.ClientSecret = ra.ClientSecret return nil } func (a *MastodonOAuthApp) PopulateWithRequestCookies(r *http.Request) error { n := a.CookieName() c, err := r.Cookie(n) if err != nil { return e.Join( fmt.Errorf("Unable to find application cookie for instance %s", a.InstanceUrl), err, ) } v, err := url.PathUnescape(c.Value) if err != nil { return e.Join( fmt.Errorf("Unable to unescape application cookie %s, value %v", n, c.Value), err, ) } var ca MastodonOAuthApp err = json.Unmarshal([]byte(v), &ca) if err != nil { return e.Join( fmt.Errorf("Unable to unmarshal application cookie %s, value %v", n, v), err, ) } a.ClientId = ca.ClientId a.ClientSecret = ca.ClientSecret return nil } func (a *MastodonOAuthApp) CookieName() string { u, _ := url.Parse(a.InstanceUrl) return "__Host-APP-MASTODON-" + strings.ToUpper(u.Hostname()) } func (a *MastodonOAuthApp) ToCookie() (*http.Cookie, error) { v, err := json.Marshal(a) if err != nil { return &http.Cookie{}, err } return &http.Cookie{ Name: a.CookieName(), Value: url.PathEscape(string(v)), SameSite: http.SameSiteStrictMode, Path: "/", Secure: true, HttpOnly: true, }, nil } templ (c MastodonOAuthClient) LoginButton() {