feat(oauth,twitter): default oauth2 implementation

This commit is contained in:
Gustavo "Guz" L. de Mello
2024-07-24 18:43:23 -03:00
parent e663abe150
commit cd2f58a24c
7 changed files with 201 additions and 93 deletions

24
internals/app/oauth.go Normal file
View File

@@ -0,0 +1,24 @@
package app
import (
"extrovert/internals/oauth"
"net/url"
"os"
)
const DOMAIN = "http://localhost:7331/"
const TWITTER_REDIRECT = "/api/twitter/oauth2"
var TWITTER_APP = func() oauth.TwitterOAuth {
ru, _ := url.Parse(DOMAIN)
ru = ru.JoinPath(TWITTER_REDIRECT)
c := oauth.NewTwitterOAuth(
os.Getenv("TWITTER_CLIENT_ID"),
os.Getenv("TWITTER_CLIENT_SECRET"),
ru,
)
return c
}()

View File

@@ -1,90 +0,0 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"extrovert/templates/pages"
"io"
"log"
"net/http"
"net/url"
"strings"
"github.com/a-h/templ"
)
type Client interface {
OAuthHandler(w http.ResponseWriter, r *http.Request)
}
type DefaultClient struct {
name string
tokenEndpoint url.URL
id string
redirectUri string
}
func (c DefaultClient) OAuthHandler(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code == "" {
log.Fatalf("TODO-ERR missing code parameter")
}
req := c.tokenEndpoint
q := req.Query()
q.Add("grant_type", "authorization_code")
q.Add("code_verifier", "challenge")
q.Add("challenge_method", "plain")
q.Add("code", code)
q.Add("client_id", c.id)
q.Add("redirect_uri", c.redirectUri)
res, err := http.Post(req.String(), "application/x-www-form-urlencoded", bytes.NewReader([]byte("")))
if err != nil {
log.Fatalf("TODO-ERR trying to get token on %s, error:\n%s", req.Host, err)
}
body, err := io.ReadAll(res.Body)
if err != nil || res.StatusCode != 200 {
log.Fatalf("TODO-ERR trying to read body on %s, body:\n%s\n\nerror:\n%s", req.Host, body, err)
}
var token DefaultClientToken
err = json.Unmarshal(body, &token)
if err != nil {
log.Fatalf("TODO-ERR trying to parse json body to token:\n%s", err)
}
cookie := http.Cookie{
Name: strings.ToUpper("__Host-TOKEN-" + c.name),
// Value: token.String(),
SameSite: http.SameSiteStrictMode,
Path: "/",
Secure: true,
}
http.SetCookie(w, &cookie)
err = pages.RedirectPopUp(
"Logged into "+c.name+"!",
"Your "+c.name+" account was succeffully logged into project-extrovert. ",
templ.SafeURL("/index.html"),
).Render(context.Background(), w)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Fatalf("TODO-ERR trying to render static page:\n%s", err)
return
}
w.WriteHeader(http.StatusOK)
}
type DefaultClientToken struct {
Type string `json:"token_type"`
Token string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}

148
internals/oauth/oauth.templ Normal file
View File

@@ -0,0 +1,148 @@
package oauth
import (
"encoding/json"
e "errors"
"net/http"
"net/url"
"strings"
"fmt"
"extrovert/internals/router/errors"
"extrovert/templates/pages"
)
type OAuthClient interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
LoginButton() templ.Component
}
type DefaultOAuthToken struct {
Type string `json:"token_type"`
Token string `json:"access_token"`
Expires int `json:"expires_in"`
Scope string `json:"scope"`
RefreshToken *string `json:"refresh_token"`
}
type DefaultOAuthClient struct {
Name string
Id string
Secret string
AuthEndpoint *url.URL
TokenEndpoint *url.URL
RedirectUri *url.URL
}
func NewDefaultOAuthClient(u *url.URL, id string, secret string, redirect *url.URL) DefaultOAuthClient {
auth, _ := url.Parse(u.String())
q := auth.Query()
q.Add("client_id", id)
q.Add("redirect_uri", redirect.String())
q.Add("response_type", "code")
q.Add("scope", "read write")
q.Add("state", "state")
q.Add("code_challenge", "challenge")
q.Add("code_challenge_method", "plain")
auth.RawQuery = q.Encode()
auth = auth.JoinPath("/oauth2/authorize")
token, _ := url.Parse(u.String())
token = token.JoinPath("/oauth2/token")
return DefaultOAuthClient{
Name: u.Hostname(),
Id: id,
Secret: secret,
AuthEndpoint: auth,
TokenEndpoint: token,
RedirectUri: redirect,
}
}
templ (c DefaultOAuthClient) LoginButton() {
<a
role="button"
href={ templ.SafeURL(c.AuthEndpoint.String()) }
rel="noopener noreferrer nofollow"
>
Login on { c.Name }
</a>
}
func (c DefaultOAuthClient) ServeHTTP(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code == "" {
errors.NewErrMissingParams("code").ServeHTTP(w, r)
return
}
req := c.TokenEndpoint
q := req.Query()
q.Add("client_id", c.Id)
q.Add("client_secret", c.Secret)
q.Add("code", code)
q.Add("redirect_uri", c.RedirectUri.String())
q.Add("grant_type", "authorization_code")
q.Add("code_verifier", "challenge")
q.Add("challenge_method", "plain")
req.RawQuery = q.Encode()
res, err := http.Post(req.String(), "application/x-www-form-urlencoded", bytes.NewReader([]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)),
)
return
}
var token DefaultOAuthToken
err = json.Unmarshal(body, &token)
if err != nil {
errors.NewErrInternal(e.New("Error while trying to validate token response body"), err).ServeHTTP(w, r)
return
}
cv, err := json.Marshal(token)
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-OAUTH-" + strings.ToUpper(c.Name),
Value: string(cv),
SameSite: http.SameSiteStrictMode,
Path: "/",
Secure: true,
HttpOnly: true,
})
err = pages.RedirectPopUp(
"Logged into "+c.Name+"!",
"Your "+c.Name+" 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)
}

View File

@@ -0,0 +1,23 @@
package oauth
import "net/url"
type TwitterOAuth struct {
DefaultOAuthClient
}
func NewTwitterOAuth(id string, secret string, redirect *url.URL) TwitterOAuth {
u, _ := url.Parse("https://api.twitter.com/2/")
c := NewDefaultOAuthClient(u, id, secret, redirect)
c.Name = "Twitter"
u, _ = url.Parse("https://twitter.com/i/oauth2/authorize")
q := c.AuthEndpoint.Query()
q.Del("scope")
q.Add("scope", "tweet.read tweet.write users.read")
u.RawQuery = q.Encode()
c.AuthEndpoint = u
return TwitterOAuth{c}
}

View File

@@ -28,7 +28,7 @@ func main() {
if *dev {
r.AddMiddleware(middlewares.NewDevelopmentMiddleware(logger))
}
r.AddMiddleware(middlewares.NewCookiesCryptoMiddleware(os.Getenv("CRYPTO_COOKIES_KEY"), logger))
r.AddMiddleware(middlewares.NewCookiesCryptoMiddleware(os.Getenv("CRYPTO_COOKIE_KEY"), logger))
err := http.ListenAndServe(fmt.Sprintf(":%v", *port), r)
if err != nil {

View File

@@ -3,6 +3,7 @@ package routes
import (
"extrovert/templates/layouts"
"extrovert/components"
"extrovert/internals/app"
"net/http"
"extrovert/internals/router/errors"
e "errors"
@@ -12,6 +13,7 @@ type Homepage struct{}
func (h Homepage) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
err := h.page().Render(context.Background(), w)
if err != nil {
errors.NewErrInternal(e.New("Unable to render dashboard"), err).ServeHTTP(w, r)
@@ -29,8 +31,7 @@ templ (h Homepage) page() {
style="height:100%;display:flex;gap:2rem;"
>
<div style="display:flex;flex-direction:column;gap:1rem;width:15rem;">
@components.LoginTwitter()
@components.LoginMastodon()
@app.TWITTER_APP.LoginButton()
</div>
<fieldset>
<textarea

View File

@@ -1,6 +1,7 @@
package routes
import (
"extrovert/internals/app"
"extrovert/internals/router"
)
@@ -8,4 +9,5 @@ var ROUTES = []router.Route{
{Pattern: "/{$}", Handler: Homepage{}},
{Pattern: "/robots.txt", Handler: RobotsTxt{}},
{Pattern: "/ai.txt", Handler: AITxt{}},
{Pattern: app.TWITTER_REDIRECT, Handler: app.TWITTER_APP},
}