mirror of
https://github.com/1f349/lavender.git
synced 2025-02-23 14:15:08 +00:00
282 lines
9.0 KiB
Go
282 lines
9.0 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/1f349/cache"
|
|
"github.com/1f349/lavender/auth"
|
|
"github.com/1f349/lavender/auth/authContext"
|
|
"github.com/1f349/lavender/database"
|
|
"github.com/1f349/lavender/database/types"
|
|
"github.com/1f349/lavender/issuer"
|
|
"github.com/1f349/lavender/url"
|
|
"github.com/google/uuid"
|
|
"github.com/mrmelon54/pronouns"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/text/language"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
type OauthCallback interface {
|
|
OAuthCallback(rw http.ResponseWriter, req *http.Request, info func(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth.UserAuth, error), cookie func(rw http.ResponseWriter, authData auth.UserAuth, loginName string) bool, redirect func(rw http.ResponseWriter, req *http.Request))
|
|
}
|
|
|
|
type flowStateData struct {
|
|
loginName string
|
|
sso *issuer.WellKnownOIDC
|
|
redirect string
|
|
}
|
|
|
|
var (
|
|
_ auth.Provider = (*OAuthLogin)(nil)
|
|
_ auth.Button = (*OAuthLogin)(nil)
|
|
)
|
|
|
|
type OAuthLogin struct {
|
|
DB *database.Queries
|
|
|
|
BaseUrl *url.URL
|
|
|
|
flow *cache.Cache[string, flowStateData]
|
|
}
|
|
|
|
func (o OAuthLogin) Init() {
|
|
o.flow = cache.New[string, flowStateData]()
|
|
}
|
|
|
|
func (o OAuthLogin) authUrlBase(ref string) *url.URL {
|
|
return o.BaseUrl.Resolve("oauth", o.Name(), ref)
|
|
}
|
|
|
|
func (o OAuthLogin) AccessState() auth.State { return auth.StateUnauthorized }
|
|
|
|
func (o OAuthLogin) Name() string { return "oauth" }
|
|
|
|
func (o OAuthLogin) AttemptLogin(ctx authContext.FormContext) error {
|
|
rCtx := ctx.Context()
|
|
|
|
login, ok := rCtx.Value(oauthServiceLogin(0)).(*issuer.WellKnownOIDC)
|
|
if !ok {
|
|
return fmt.Errorf("missing issuer wellknown")
|
|
}
|
|
loginName := rCtx.Value("login_full").(string)
|
|
loginUn := rCtx.Value("login_username").(string)
|
|
|
|
// save state for use later
|
|
state := login.Config.Namespace + ":" + uuid.NewString()
|
|
o.flow.Set(state, flowStateData{
|
|
loginName: loginName,
|
|
sso: login,
|
|
redirect: ctx.Request().PostFormValue("redirect"),
|
|
}, time.Now().Add(15*time.Minute))
|
|
|
|
// generate oauth2 config and redirect to authorize URL
|
|
oa2conf := login.OAuth2Config
|
|
oa2conf.RedirectURL = o.authUrlBase("callback").String()
|
|
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
|
|
|
|
return auth.RedirectError{Target: nextUrl, Code: http.StatusFound}
|
|
}
|
|
|
|
func (o OAuthLogin) OAuthCallback(rw http.ResponseWriter, req *http.Request, info func(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth.UserAuth, error), cookie func(rw http.ResponseWriter, authData auth.UserAuth, loginName string) bool, redirect func(rw http.ResponseWriter, req *http.Request)) {
|
|
flowState, ok := o.flow.Get(req.FormValue("state"))
|
|
if !ok {
|
|
http.Error(rw, "Invalid flow state", http.StatusBadRequest)
|
|
return
|
|
}
|
|
token, err := flowState.sso.OAuth2Config.Exchange(context.Background(), req.FormValue("code"), oauth2.SetAuthURLParam("redirect_uri", o.authUrlBase("callback").String()))
|
|
if err != nil {
|
|
http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
userAuth, err := info(req, flowState.sso, token)
|
|
if err != nil {
|
|
http.Error(rw, "Failed to update external user info", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if cookie(rw, userAuth, flowState.loginName) {
|
|
http.Error(rw, "Failed to save login cookie", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if flowState.redirect != "" {
|
|
req.Form.Set("redirect", flowState.redirect)
|
|
}
|
|
redirect(rw, req)
|
|
}
|
|
|
|
func (o OAuthLogin) RenderButtonTemplate(ctx authContext.TemplateContext) {
|
|
// o.authUrlBase("button")
|
|
// provide something non-nil
|
|
ctx.Render(struct {
|
|
Href string
|
|
ButtonName string
|
|
}{
|
|
Href: o.authUrlBase("button").String(),
|
|
ButtonName: "Login with Unknown OAuth Button", // TODO: actually get the service name
|
|
})
|
|
}
|
|
|
|
type oauthServiceLogin int
|
|
|
|
func WithWellKnown(ctx context.Context, login *issuer.WellKnownOIDC) context.Context {
|
|
return context.WithValue(ctx, oauthServiceLogin(0), login)
|
|
}
|
|
|
|
func (o OAuthLogin) updateExternalUserInfo(req *http.Request, sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth.UserAuth, error) {
|
|
sessionData, err := o.fetchUserInfo(sso, token)
|
|
if err != nil || sessionData.Subject == "" {
|
|
return auth.UserAuth{}, fmt.Errorf("failed to fetch user info")
|
|
}
|
|
|
|
// TODO(melon): fix this to use a merging of lavender and tulip auth
|
|
|
|
// find an existing user with the matching oauth2 namespace and subject
|
|
var userSubject string
|
|
err = o.DB.UseTx(req.Context(), func(tx *database.Queries) (err error) {
|
|
userSubject, err = tx.FindUserByAuth(req.Context(), database.FindUserByAuthParams{
|
|
AuthType: types.AuthTypeOauth2,
|
|
AuthNamespace: sso.Namespace,
|
|
AuthUser: sessionData.Subject,
|
|
})
|
|
return
|
|
})
|
|
switch {
|
|
case err == nil:
|
|
// user already exists
|
|
err = o.DB.UseTx(req.Context(), func(tx *database.Queries) (err error) {
|
|
return o.updateOAuth2UserProfile(req.Context(), tx, sessionData)
|
|
})
|
|
return auth.UserAuth{
|
|
Subject: userSubject,
|
|
Factor: auth.StateExtended,
|
|
UserInfo: sessionData.UserInfo,
|
|
}, err
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
// happy path for registration
|
|
break
|
|
default:
|
|
// another error occurred
|
|
return auth.UserAuth{}, err
|
|
}
|
|
|
|
// guard for disabled registration
|
|
if !sso.Config.Registration {
|
|
return auth.UserAuth{}, fmt.Errorf("registration is not enabled for this authentication source")
|
|
}
|
|
|
|
// TODO(melon): rework this
|
|
name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User")
|
|
uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost")
|
|
uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified")
|
|
|
|
err = o.DB.UseTx(req.Context(), func(tx *database.Queries) (err error) {
|
|
userSubject, err = tx.AddOAuthUser(req.Context(), database.AddOAuthUserParams{
|
|
Email: uEmail,
|
|
EmailVerified: uEmailVerified,
|
|
Name: name,
|
|
Username: sessionData.UserInfo.GetStringFromKeysOrEmpty("login", "preferred_username"),
|
|
AuthNamespace: sso.Namespace,
|
|
AuthUser: sessionData.UserInfo.GetStringOrEmpty("sub"),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// if adding the user succeeds then update the profile
|
|
return o.updateOAuth2UserProfile(req.Context(), tx, sessionData)
|
|
})
|
|
if err != nil {
|
|
return auth.UserAuth{}, err
|
|
}
|
|
|
|
// only continues if the above tx succeeds
|
|
if err := o.DB.UseTx(req.Context(), func(tx *database.Queries) error {
|
|
return tx.UpdateUserToken(req.Context(), database.UpdateUserTokenParams{
|
|
AccessToken: sql.NullString{String: token.AccessToken, Valid: true},
|
|
RefreshToken: sql.NullString{String: token.RefreshToken, Valid: true},
|
|
TokenExpiry: sql.NullTime{Time: token.Expiry, Valid: true},
|
|
Subject: sessionData.Subject,
|
|
})
|
|
}); err != nil {
|
|
return auth.UserAuth{}, err
|
|
}
|
|
|
|
// TODO(melon): this feels bad
|
|
sessionData = auth.UserAuth{
|
|
Subject: userSubject,
|
|
Factor: auth.StateExtended,
|
|
UserInfo: sessionData.UserInfo,
|
|
}
|
|
|
|
return sessionData, nil
|
|
}
|
|
|
|
func (o OAuthLogin) updateOAuth2UserProfile(ctx context.Context, tx *database.Queries, sessionData auth.UserAuth) error {
|
|
// all of these updates must succeed
|
|
return tx.UseTx(ctx, func(tx *database.Queries) error {
|
|
name := sessionData.UserInfo.GetStringOrDefault("name", "Unknown User")
|
|
|
|
err := tx.ModifyUserRemoteLogin(ctx, database.ModifyUserRemoteLoginParams{
|
|
Login: sessionData.UserInfo.GetStringFromKeysOrEmpty("login", "preferred_username"),
|
|
ProfileUrl: sessionData.UserInfo.GetStringOrEmpty("profile"),
|
|
Subject: sessionData.Subject,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pronoun, err := pronouns.FindPronoun(sessionData.UserInfo.GetStringOrEmpty("pronouns"))
|
|
if err != nil {
|
|
pronoun = pronouns.TheyThem
|
|
}
|
|
locale, err := language.Parse(sessionData.UserInfo.GetStringOrEmpty("locale"))
|
|
if err != nil {
|
|
locale = language.AmericanEnglish
|
|
}
|
|
|
|
return tx.ModifyProfile(ctx, database.ModifyProfileParams{
|
|
Name: name,
|
|
Picture: sessionData.UserInfo.GetStringOrEmpty("profile"),
|
|
Website: sessionData.UserInfo.GetStringOrEmpty("website"),
|
|
Pronouns: types.UserPronoun{Pronoun: pronoun},
|
|
Birthdate: sessionData.UserInfo.GetNullDate("birthdate"),
|
|
Zone: sessionData.UserInfo.GetStringOrDefault("zoneinfo", "UTC"),
|
|
Locale: types.UserLocale{Tag: locale},
|
|
UpdatedAt: time.Now(),
|
|
Subject: sessionData.Subject,
|
|
})
|
|
})
|
|
}
|
|
|
|
func (o OAuthLogin) fetchUserInfo(sso *issuer.WellKnownOIDC, token *oauth2.Token) (auth.UserAuth, error) {
|
|
res, err := sso.OAuth2Config.Client(context.Background(), token).Get(sso.UserInfoEndpoint)
|
|
if err != nil || res.StatusCode != http.StatusOK {
|
|
return auth.UserAuth{}, fmt.Errorf("request failed")
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
var userInfoJson auth.UserInfoFields
|
|
if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil {
|
|
return auth.UserAuth{}, err
|
|
}
|
|
subject, ok := userInfoJson.GetString("sub")
|
|
if !ok {
|
|
return auth.UserAuth{}, fmt.Errorf("invalid subject")
|
|
}
|
|
|
|
// TODO(melon): there is no need for this
|
|
//subject += "@" + sso.Config.Namespace
|
|
|
|
return auth.UserAuth{
|
|
Subject: subject,
|
|
Factor: auth.StateExtended,
|
|
UserInfo: userInfoJson,
|
|
}, nil
|
|
}
|