diff --git a/backend/.testcoverage.yml b/backend/.testcoverage.yml
index 17444139..b52eaeff 100644
--- a/backend/.testcoverage.yml
+++ b/backend/.testcoverage.yml
@@ -18,4 +18,4 @@ threshold:
# package: 30
# (optional; default 0)
# The minimum total coverage project should have
- total: 33
+ total: 30
diff --git a/backend/embed/api_docs/components/AuthConfigObject.json b/backend/embed/api_docs/components/AuthConfigObject.json
index 68d363a3..9a6aa396 100644
--- a/backend/embed/api_docs/components/AuthConfigObject.json
+++ b/backend/embed/api_docs/components/AuthConfigObject.json
@@ -7,7 +7,7 @@
"enum": [
"local",
"ldap",
- "oidc"
+ "oauth"
]
}
}
diff --git a/backend/embed/api_docs/components/UserAuthObject.json b/backend/embed/api_docs/components/UserAuthObject.json
index 4c1979d5..1059b43c 100644
--- a/backend/embed/api_docs/components/UserAuthObject.json
+++ b/backend/embed/api_docs/components/UserAuthObject.json
@@ -24,7 +24,7 @@
},
"type": {
"type": "string",
- "pattern": "^(local|ldap|oidc)$"
+ "pattern": "^(local|ldap|oauth)$"
}
}
}
diff --git a/backend/embed/api_docs/components/UserObject.json b/backend/embed/api_docs/components/UserObject.json
index f5776ebc..94a6dc5d 100644
--- a/backend/embed/api_docs/components/UserObject.json
+++ b/backend/embed/api_docs/components/UserObject.json
@@ -53,7 +53,7 @@
},
"type": {
"type": "string",
- "pattern": "^(local|ldap|oidc)$"
+ "pattern": "^(local|ldap|oauth)$"
}
}
},
diff --git a/backend/embed/migrations/mysql/20201013035839_initial_data.sql b/backend/embed/migrations/mysql/20201013035839_initial_data.sql
index 5be06a48..9f24a6a0 100644
--- a/backend/embed/migrations/mysql/20201013035839_initial_data.sql
+++ b/backend/embed/migrations/mysql/20201013035839_initial_data.sql
@@ -48,8 +48,8 @@ INSERT INTO `setting` (
(
ROUND(UNIX_TIMESTAMP(CURTIME(4)) * 1000),
ROUND(UNIX_TIMESTAMP(CURTIME(4)) * 1000),
- "oidc-auth",
- "Configuration for OIDC authentication",
+ "oauth-auth",
+ "Configuration for OAuth authentication",
'{}' -- remember this is json
),
(
diff --git a/backend/embed/migrations/postgres/20201013035839_initial_data.sql b/backend/embed/migrations/postgres/20201013035839_initial_data.sql
index fb3483fd..0937851a 100644
--- a/backend/embed/migrations/postgres/20201013035839_initial_data.sql
+++ b/backend/embed/migrations/postgres/20201013035839_initial_data.sql
@@ -48,8 +48,8 @@ INSERT INTO "setting" (
(
EXTRACT(EPOCH FROM TIMESTAMP '2011-05-17 10:40:28.876944') * 1000,
EXTRACT(EPOCH FROM TIMESTAMP '2011-05-17 10:40:28.876944') * 1000,
- 'oidc-auth',
- 'Configuration for OIDC authentication',
+ 'oauth-auth',
+ 'Configuration for OAuth authentication',
'{}' -- remember this is json
),
(
diff --git a/backend/embed/migrations/sqlite/20201013035839_initial_data.sql b/backend/embed/migrations/sqlite/20201013035839_initial_data.sql
index 90beda89..5bfe4016 100644
--- a/backend/embed/migrations/sqlite/20201013035839_initial_data.sql
+++ b/backend/embed/migrations/sqlite/20201013035839_initial_data.sql
@@ -47,8 +47,8 @@ INSERT INTO `setting` (
(
unixepoch() * 1000,
unixepoch() * 1000,
- "oidc-auth",
- "Configuration for OIDC authentication",
+ "oauth-auth",
+ "Configuration for OAuth authentication",
'{}' -- remember this is json
),
(
diff --git a/backend/go.mod b/backend/go.mod
index 7ce62f0b..65789d94 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -24,6 +24,7 @@ require (
github.com/vrischmann/envconfig v1.3.0
go.uber.org/goleak v1.3.0
golang.org/x/crypto v0.27.0
+ golang.org/x/oauth2 v0.23.0
gorm.io/datatypes v1.2.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.9
diff --git a/backend/go.sum b/backend/go.sum
index ba883f58..d220e47c 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -205,6 +205,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
+golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
diff --git a/backend/internal/api/handler/auth.go b/backend/internal/api/handler/auth.go
index e362c4ac..f4f2799c 100644
--- a/backend/internal/api/handler/auth.go
+++ b/backend/internal/api/handler/auth.go
@@ -70,8 +70,6 @@ func NewToken() func(http.ResponseWriter, *http.Request) {
switch payload.Type {
case "ldap":
newTokenLDAP(w, r, payload)
- case "oidc":
- newTokenOIDC(w, r, payload)
case "local":
newTokenLocal(w, r, payload)
}
@@ -199,10 +197,6 @@ func newTokenLDAP(w http.ResponseWriter, r *http.Request, payload tokenPayload)
}
}
-func newTokenOIDC(w http.ResponseWriter, r *http.Request, _ tokenPayload) {
- h.ResultErrorJSON(w, r, http.StatusInternalServerError, "NOT YET SUPPORTED", nil)
-}
-
// RefreshToken an existing token by given them a new one with the same claims
// Route: POST /auth/refresh
func RefreshToken() func(http.ResponseWriter, *http.Request) {
diff --git a/backend/internal/api/handler/helpers.go b/backend/internal/api/handler/helpers.go
index 8bc6a5e4..e4a36253 100644
--- a/backend/internal/api/handler/helpers.go
+++ b/backend/internal/api/handler/helpers.go
@@ -21,11 +21,22 @@ func getPageInfoFromRequest(r *http.Request) (model.PageInfo, error) {
return pageInfo, err
}
- // pageInfo.Sort = middleware.GetSortFromContext(r)
-
return pageInfo, nil
}
+func getQueryVarString(r *http.Request, varName string, required bool, defaultValue string) (string, error) {
+ queryValues := r.URL.Query()
+ varValue := queryValues.Get(varName)
+
+ if varValue == "" && required {
+ return "", eris.Errorf("%v was not supplied in the request", varName)
+ } else if varValue == "" {
+ return defaultValue, nil
+ }
+
+ return varValue, nil
+}
+
func getQueryVarInt(r *http.Request, varName string, required bool, defaultValue int) (int, error) {
queryValues := r.URL.Query()
varValue := queryValues.Get(varName)
diff --git a/backend/internal/api/handler/oauth.go b/backend/internal/api/handler/oauth.go
new file mode 100644
index 00000000..f88c5b84
--- /dev/null
+++ b/backend/internal/api/handler/oauth.go
@@ -0,0 +1,156 @@
+package handler
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ h "npm/internal/api/http"
+ "npm/internal/entity/auth"
+ "npm/internal/entity/setting"
+ "npm/internal/entity/user"
+ "npm/internal/errors"
+ njwt "npm/internal/jwt"
+ "npm/internal/logger"
+
+ "gorm.io/gorm"
+)
+
+// getRequestIPAddress will use X-FORWARDED-FOR header if it exists
+// otherwise it will use RemoteAddr
+func getRequestIPAddress(r *http.Request) string {
+ // this Get is case insensitive
+ xff := r.Header.Get("X-FORWARDED-FOR")
+ if xff != "" {
+ ip, _, _ := strings.Cut(xff, ",")
+ return strings.TrimSpace(ip)
+ }
+ return r.RemoteAddr
+}
+
+// OAuthLogin ...
+// Route: GET /oauth/login
+func OAuthLogin() func(http.ResponseWriter, *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !setting.AuthMethodEnabled(auth.TypeOAuth) {
+ h.ResultErrorJSON(w, r, http.StatusNotFound, "Not found", nil)
+ return
+ }
+
+ redirectBase, _ := getQueryVarString(r, "redirect_base", false, "")
+ url, err := auth.OAuthLogin(redirectBase, getRequestIPAddress(r))
+ if err != nil {
+ h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
+ return
+ }
+
+ h.ResultResponseJSON(w, r, http.StatusOK, url)
+ }
+}
+
+// OAuthRedirect ...
+// Route: GET /oauth/redirect
+func OAuthRedirect() func(http.ResponseWriter, *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !setting.AuthMethodEnabled(auth.TypeOAuth) {
+ h.ResultErrorJSON(w, r, http.StatusNotFound, "Not found", nil)
+ return
+ }
+
+ code, err := getQueryVarString(r, "code", true, "")
+ if err != nil {
+ h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
+ return
+ }
+
+ ou, err := auth.OAuthReturn(r.Context(), code, getRequestIPAddress(r))
+ if err != nil {
+ h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
+ return
+ }
+
+ if ou.Identifier == "" {
+ h.ResultErrorJSON(w, r, http.StatusBadRequest, "User found, but OAuth identifier seems misconfigured", nil)
+ return
+ }
+
+ jwt, err := newTokenOAuth(ou)
+ if err != nil {
+ h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
+ return
+ }
+
+ // encode jwt to json
+ j, _ := json.Marshal(jwt)
+
+ // Redirect to frontend with success
+ http.Redirect(w, r, fmt.Sprintf("/?token_response=%s", url.QueryEscape(string(j))), http.StatusSeeOther)
+ }
+}
+
+// newTokenOAuth takes a OAuthUser and creates a new token,
+// optionally creating a new user if one does not exist
+func newTokenOAuth(ou *auth.OAuthUser) (*njwt.GeneratedResponse, error) {
+ // Get OAuth settings
+ oAuthSettings, err := setting.GetOAuthSettings()
+ if err != nil {
+ logger.Error("OAuth settings not found", err)
+ return nil, err
+ }
+
+ // Get Auth by identity
+ authObj, authErr := auth.GetByIdenityType(ou.GetID(), auth.TypeOAuth)
+ if authErr == gorm.ErrRecordNotFound {
+ // Auth is not found for this identity. We can create it
+ if !oAuthSettings.AutoCreateUser {
+ // user does not have an auth record
+ // and auto create is disabled. Showing account disabled error
+ // for the time being
+ return nil, errors.ErrUserDisabled
+ }
+
+ // Attempt to find user by email
+ foundUser, err := user.GetByEmail(ou.GetEmail())
+ if err == gorm.ErrRecordNotFound {
+ // User not found, create user
+ foundUser, err = user.CreateFromOAuthUser(ou)
+ if err != nil {
+ logger.Error("user.CreateFromOAuthUser", err)
+ return nil, err
+ }
+ logger.Info("Created user from OAuth: %s, %s", ou.GetID(), foundUser.Email)
+ } else if err != nil {
+ logger.Error("user.GetByEmail", err)
+ return nil, err
+ }
+
+ // Create auth record and attach to this user
+ authObj = auth.Model{
+ UserID: foundUser.ID,
+ Type: auth.TypeOAuth,
+ Identity: ou.GetID(),
+ }
+ if err := authObj.Save(); err != nil {
+ logger.Error("auth.Save", err)
+ return nil, err
+ }
+ logger.Info("Created OAuth auth for user: %s, %s", ou.GetID(), foundUser.Email)
+ } else if authErr != nil {
+ logger.Error("auth.GetByIdenityType", err)
+ return nil, authErr
+ }
+
+ userObj, userErr := user.GetByID(authObj.UserID)
+ if userErr != nil {
+ return nil, userErr
+ }
+
+ if userObj.IsDisabled {
+ return nil, errors.ErrUserDisabled
+ }
+
+ jwt, err := njwt.Generate(&userObj, false)
+ return &jwt, err
+}
diff --git a/backend/internal/api/middleware/log.go b/backend/internal/api/middleware/log.go
new file mode 100644
index 00000000..12e80dce
--- /dev/null
+++ b/backend/internal/api/middleware/log.go
@@ -0,0 +1,16 @@
+package middleware
+
+import (
+ "net/http"
+
+ "npm/internal/logger"
+)
+
+// Log will print out route information to the logger
+// only when debug is enabled
+func Log(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ logger.Debug("Request: %s %s", r.Method, r.URL.Path)
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go
index 60a4d08c..570dd9be 100644
--- a/backend/internal/api/router.go
+++ b/backend/internal/api/router.go
@@ -50,6 +50,7 @@ func NewRouter() http.Handler {
middleware.Expansion,
middleware.DecodeAuth(),
middleware.BodyContext(),
+ middleware.Log,
)
return applyRoutes(r)
@@ -61,6 +62,12 @@ func applyRoutes(r chi.Router) chi.Router {
r.NotFound(handler.NotFound())
r.MethodNotAllowed(handler.NotAllowed())
+ // OAuth endpoints aren't technically API endpoints
+ r.With(middleware.EnforceSetup()).Route("/oauth", func(r chi.Router) {
+ r.Get("/login", handler.OAuthLogin())
+ r.Get("/redirect", handler.OAuthRedirect())
+ })
+
// SSE - requires a sse token as the `jwt` get parameter
// Exists inside /api but it's here so that we can skip the Timeout middleware
// that applies to other endpoints.
diff --git a/backend/internal/api/schema/get_token.go b/backend/internal/api/schema/get_token.go
index 9188d8fc..14292892 100644
--- a/backend/internal/api/schema/get_token.go
+++ b/backend/internal/api/schema/get_token.go
@@ -18,7 +18,7 @@ func GetToken() string {
"properties": {
"type": {
"type": "string",
- "enum": ["local", "ldap", "oidc"]
+ "enum": ["local", "ldap"]
},
"identity": %s,
"secret": %s
diff --git a/backend/internal/entity/auth/ldap.go b/backend/internal/entity/auth/ldap.go
index d94cd74a..9a72f0cf 100644
--- a/backend/internal/entity/auth/ldap.go
+++ b/backend/internal/entity/auth/ldap.go
@@ -70,7 +70,7 @@ func ldapSearchUser(l *ldap3.Conn, ldapSettings setting.LDAPSettings, username s
0,
false,
strings.Replace(ldapSettings.SelfFilter, "{{USERNAME}}", username, 1),
- nil, // []string{"name"},
+ nil,
nil,
)
diff --git a/backend/internal/entity/auth/model.go b/backend/internal/entity/auth/model.go
index 5348763f..bab6082b 100644
--- a/backend/internal/entity/auth/model.go
+++ b/backend/internal/entity/auth/model.go
@@ -12,7 +12,7 @@ import (
const (
TypeLocal = "local"
TypeLDAP = "ldap"
- TypeOIDC = "oidc"
+ TypeOAuth = "oauth"
)
// Model is the model
diff --git a/backend/internal/entity/auth/oauth.go b/backend/internal/entity/auth/oauth.go
new file mode 100644
index 00000000..1507690d
--- /dev/null
+++ b/backend/internal/entity/auth/oauth.go
@@ -0,0 +1,216 @@
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "npm/internal/entity/setting"
+ "npm/internal/logger"
+
+ cache "github.com/patrickmn/go-cache"
+ "github.com/rotisserie/eris"
+ "golang.org/x/oauth2"
+)
+
+// AuthCache is a cache item that stores the Admin API data for each admin that has been requesting endpoints
+var OAuthCache *cache.Cache
+
+// OAuthCacheInit will create a new Memory Cache
+func OAuthCacheInit() {
+ if OAuthCache == nil {
+ logger.Debug("Creating a new OAuthCache")
+ OAuthCache = cache.New(5*time.Minute, 5*time.Minute)
+ }
+}
+
+// OAuthUser is the OAuth User
+type OAuthUser struct {
+ Identifier string `json:"identifier"`
+ Token string `json:"token"`
+ Resource map[string]interface{} `json:"resource"`
+}
+
+// GetEmail will return an email address even if it can't be known in the
+// Resource
+func (m *OAuthUser) GetResourceField(field string) string {
+ if m.Resource != nil {
+ if value, ok := m.Resource[field]; ok {
+ return value.(string)
+ }
+ }
+ return ""
+}
+
+// GetEmail will return an email address even if it can't be known in the
+// Resource
+func (m *OAuthUser) GetID() string {
+ if m.Identifier != "" {
+ return m.Identifier
+ }
+
+ fields := []string{
+ "uid",
+ "user_id",
+ "username",
+ "preferred_username",
+ "email",
+ "mail",
+ }
+
+ for _, field := range fields {
+ if val := m.GetResourceField(field); val != "" {
+ return val
+ }
+ }
+
+ return ""
+}
+
+// GetName attempts to get a name from the resource
+// using different fields
+func (m *OAuthUser) GetName() string {
+ fields := []string{
+ "nickname",
+ "given_name",
+ "name",
+ "preferred_username",
+ "username",
+ }
+
+ for _, field := range fields {
+ if name := m.GetResourceField(field); name != "" {
+ return name
+ }
+ }
+
+ // Fallback:
+ return m.Identifier
+}
+
+// GetEmail will return an email address even if it can't be known in the
+// Resource
+func (m *OAuthUser) GetEmail() string {
+ // See if there's an email field first
+ if email := m.GetResourceField("email"); email != "" {
+ return email
+ }
+
+ // Return the identifier if it looks like an email
+ if m.Identifier != "" {
+ if strings.Contains(m.Identifier, "@") {
+ return m.Identifier
+ }
+ return fmt.Sprintf("%s@oauth", m.Identifier)
+ }
+ return ""
+}
+
+func getOAuth2Config() (*oauth2.Config, *setting.OAuthSettings, error) {
+ oauthSettings, err := setting.GetOAuthSettings()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ if oauthSettings.ClientID == "" || oauthSettings.ClientSecret == "" || oauthSettings.AuthURL == "" || oauthSettings.TokenURL == "" {
+ return nil, nil, eris.New("oauth-settings-incorrect")
+ }
+
+ return &oauth2.Config{
+ ClientID: oauthSettings.ClientID,
+ ClientSecret: oauthSettings.ClientSecret,
+ Scopes: oauthSettings.Scopes,
+ Endpoint: oauth2.Endpoint{
+ AuthURL: oauthSettings.AuthURL,
+ TokenURL: oauthSettings.TokenURL,
+ },
+ }, &oauthSettings, nil
+}
+
+// OAuthLogin ...
+func OAuthLogin(redirectBase, ipAddress string) (string, error) {
+ OAuthCacheInit()
+
+ conf, _, err := getOAuth2Config()
+ if err != nil {
+ return "", err
+ }
+
+ verifier := oauth2.GenerateVerifier()
+ OAuthCache.Set(getCacheKey(ipAddress), verifier, cache.DefaultExpiration)
+
+ // todo: state should be unique to the incoming IP address of the requester, I guess
+ url := conf.AuthCodeURL("state", oauth2.AccessTypeOnline, oauth2.S256ChallengeOption(verifier))
+
+ if redirectBase != "" {
+ url = url + "&redirect_uri=" + redirectBase + "/oauth/redirect"
+ }
+
+ logger.Debug("URL: %s", url)
+ return url, nil
+}
+
+// OAuthReturn ...
+func OAuthReturn(ctx context.Context, code, ipAddress string) (*OAuthUser, error) {
+ // Just in case...
+ OAuthCacheInit()
+
+ conf, oauthSettings, err := getOAuth2Config()
+ if err != nil {
+ return nil, err
+ }
+
+ verifier, found := OAuthCache.Get(getCacheKey(ipAddress))
+ if !found {
+ return nil, eris.New("oauth-verifier-not-found")
+ }
+
+ // Use the authorization code that is pushed to the redirect
+ // URL. Exchange will do the handshake to retrieve the
+ // initial access token. The HTTP Client returned by
+ // conf.Client will refresh the token as necessary.
+ tok, err := conf.Exchange(ctx, code, oauth2.VerifierOption(verifier.(string)))
+ if err != nil {
+ return nil, err
+ }
+
+ // At this stage, the token is the JWT as given by the oauth server.
+ // we need to use that to get more info about this user,
+ // and then we'll create our own jwt for use later.
+
+ client := conf.Client(ctx, tok)
+ resp, err := client.Get(oauthSettings.ResourceURL)
+ if err != nil {
+ return nil, err
+ }
+
+ // nolint: errcheck, gosec
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ ou := OAuthUser{
+ Token: tok.AccessToken,
+ }
+
+ // unmarshal the body into a interface
+ if err := json.Unmarshal(body, &ou.Resource); err != nil {
+ return nil, err
+ }
+
+ // Attempt to get the identifier from the resource
+ if oauthSettings.Identifier != "" {
+ ou.Identifier = ou.GetResourceField(oauthSettings.Identifier)
+ }
+
+ return &ou, nil
+}
+
+func getCacheKey(ipAddress string) string {
+ return fmt.Sprintf("oauth-%s", ipAddress)
+}
diff --git a/backend/internal/entity/setting/auth_methods.go b/backend/internal/entity/setting/auth_methods.go
index ea6db4dd..3d54f295 100644
--- a/backend/internal/entity/setting/auth_methods.go
+++ b/backend/internal/entity/setting/auth_methods.go
@@ -2,19 +2,31 @@ package setting
import (
"encoding/json"
+ "slices"
)
// GetAuthMethods returns the authentication methods enabled for this site
func GetAuthMethods() ([]string, error) {
- var l []string
var m Model
if err := m.LoadByName("auth-methods"); err != nil {
- return l, err
+ return nil, err
}
- if err := json.Unmarshal([]byte(m.Value.String()), &l); err != nil {
- return l, err
+ var r []string
+ if err := json.Unmarshal([]byte(m.Value.String()), &r); err != nil {
+ return nil, err
}
- return l, nil
+ return r, nil
+}
+
+// AuthMethodEnabled checks that the auth method given is
+// enabled in the db setting
+func AuthMethodEnabled(method string) bool {
+ r, err := GetAuthMethods()
+ if err != nil {
+ return false
+ }
+
+ return slices.Contains(r, method)
}
diff --git a/backend/internal/entity/setting/oauth.go b/backend/internal/entity/setting/oauth.go
new file mode 100644
index 00000000..bfc5e048
--- /dev/null
+++ b/backend/internal/entity/setting/oauth.go
@@ -0,0 +1,42 @@
+package setting
+
+import (
+ "encoding/json"
+)
+
+// OAuthSettings are the settings for OAuth that come from
+// the `oauth-auth` setting value
+type OAuthSettings struct {
+ AutoCreateUser bool `json:"auto_create_user"`
+ ClientID string `json:"client_id"`
+ ClientSecret string `json:"client_secret"`
+ AuthURL string `json:"authorization_url"`
+ TokenURL string `json:"token_url"`
+ Identifier string `json:"identifier"`
+ LogoutURL string `json:"logout_url"`
+ Scopes []string `json:"scopes"`
+ ResourceURL string `json:"resource_url"`
+}
+
+// GetOAuthSettings will return the OAuth settings
+func GetOAuthSettings() (OAuthSettings, error) {
+ var o OAuthSettings
+ var m Model
+ if err := m.LoadByName("oauth-auth"); err != nil {
+ return o, err
+ }
+
+ if err := json.Unmarshal([]byte(m.Value.String()), &o); err != nil {
+ return o, err
+ }
+
+ o.ApplyDefaults()
+ return o, nil
+}
+
+// ApplyDefaults will ensure there are defaults set
+func (m *OAuthSettings) ApplyDefaults() {
+ if m.Identifier == "" {
+ m.Identifier = "email"
+ }
+}
diff --git a/backend/internal/entity/user/methods.go b/backend/internal/entity/user/methods.go
index 3909288d..266d1660 100644
--- a/backend/internal/entity/user/methods.go
+++ b/backend/internal/entity/user/methods.go
@@ -117,3 +117,14 @@ func CreateFromLDAPUser(ldapUser *auth.LDAPUser) (Model, error) {
user.generateGravatar()
return user, err
}
+
+// CreateFromOAuthUser will create a user from an OAuth user object
+func CreateFromOAuthUser(ou *auth.OAuthUser) (Model, error) {
+ user := Model{
+ Email: ou.GetEmail(),
+ Name: ou.GetName(),
+ }
+ err := user.Save()
+ user.generateGravatar()
+ return user, err
+}
diff --git a/docker/rootfs/etc/nginx/conf.d/dev.conf b/docker/rootfs/etc/nginx/conf.d/dev.conf
index fee694c5..34d0340d 100644
--- a/docker/rootfs/etc/nginx/conf.d/dev.conf
+++ b/docker/rootfs/etc/nginx/conf.d/dev.conf
@@ -11,6 +11,7 @@ server {
try_files /index.html /coverage.html;
}
+ # go server
location /api/ {
add_header X-Served-By $host;
chunked_transfer_encoding off;
@@ -26,10 +27,27 @@ server {
proxy_pass http://127.0.0.1:3000/api/;
}
+ # go server
+ location /oauth/ {
+ add_header X-Served-By $host;
+ chunked_transfer_encoding off;
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header Connection '';
+ proxy_set_header X-Forwarded-Scheme $scheme;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Accel-Buffering no;
+ proxy_pass http://127.0.0.1:3000/oauth/;
+ }
+
location ~ .html {
try_files $uri =404;
}
+ # vite dev server
location / {
add_header X-Served-By $host;
proxy_http_version 1.1;
diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx
index cc93952f..5d3616ba 100644
--- a/frontend/src/Router.tsx
+++ b/frontend/src/Router.tsx
@@ -1,7 +1,8 @@
import { lazy, Suspense } from "react";
-import { BrowserRouter, Routes, Route } from "react-router-dom";
+import { BrowserRouter, Route, Routes } from "react-router-dom";
+import { TokenResponse } from "src/api/npm";
import { SiteWrapper, SpinnerPage, Unhealthy } from "src/components";
import { useAuthState, useLocaleState } from "src/context";
import { useHealth } from "src/hooks";
@@ -24,10 +25,21 @@ const Users = lazy(() => import("src/pages/Users"));
function Router() {
const health = useHealth();
- const { authenticated } = useAuthState();
+ const { authenticated, handleTokenUpdate } = useAuthState();
const { locale } = useLocaleState();
const Spinner = ;
+ // Load token from URL Query Params
+ const searchParams = new URLSearchParams(document.location.search);
+ const t = searchParams.get("token_response");
+ if (t) {
+ const tokenResponse: TokenResponse = JSON.parse(t);
+ handleTokenUpdate(tokenResponse);
+ window.location.href = "/";
+ return;
+ }
+ // End Load token from URL Query Params
+
if (health.isLoading) {
return Spinner;
}
diff --git a/frontend/src/components/SiteWrapper.tsx b/frontend/src/components/SiteWrapper.tsx
index e2e2e98e..4062da24 100644
--- a/frontend/src/components/SiteWrapper.tsx
+++ b/frontend/src/components/SiteWrapper.tsx
@@ -1,4 +1,4 @@
-import { useEffect, ReactNode } from "react";
+import { ReactNode, useEffect } from "react";
import { Box, Container, useToast } from "@chakra-ui/react";
import { useQueryClient } from "@tanstack/react-query";
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx
index 241b9549..c3a196e8 100644
--- a/frontend/src/context/AuthContext.tsx
+++ b/frontend/src/context/AuthContext.tsx
@@ -9,6 +9,7 @@ import AuthStore from "src/modules/AuthStore";
// Context
export interface AuthContextType {
authenticated: boolean;
+ handleTokenUpdate: (response: TokenResponse) => void;
login: (type: string, username: string, password: string) => Promise;
logout: () => void;
token?: string;
@@ -62,7 +63,7 @@ function AuthProvider({
true,
);
- const value = { authenticated, login, logout };
+ const value = { authenticated, login, logout, handleTokenUpdate };
return {children};
}