diff --git a/auth/auth.go b/auth/auth.go index ebe5738..e328ab4 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -2,130 +2,12 @@ package auth import ( - "fmt" "net/http" - "net/url" - "strings" "time" "github.com/Darkness4/auth-htmx/jwt" - "github.com/gorilla/csrf" - "github.com/rs/zerolog/log" ) -// Auth is a service that provides HTTP handlers and middlewares used for authentication. -type Auth struct { - JWTSecret jwt.Secret - Providers map[string]Provider -} - -// Login is the handler that redirect to the authentication page of the OAuth Provider. -func (a *Auth) Login() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - val, err := url.ParseQuery(r.URL.RawQuery) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - p := val.Get("provider") - - provider, ok := a.Providers[strings.ToLower(p)] - if !ok { - http.Error(w, "auth provider not known", http.StatusUnauthorized) - return - } - - token := csrf.Token(r) - cookie := &http.Cookie{ - Name: "csrf_token", - Value: token, - Expires: time.Now().Add(1 * time.Minute), // Set expiration time as needed - HttpOnly: true, - } - // State contain the provider and the csrf token. - state := fmt.Sprintf("%s,%s", token, p) - http.SetCookie(w, cookie) - http.Redirect( - w, - r, - provider.AuthCodeURL(state), - http.StatusFound, - ) - } -} - -// CallBack is the handler called after login. -// -// It: -// -// 1. Fetches the accessToken -// 2. Fetches some user info and wrap them in a JWT token -// 3. Store the JWT token in a cookie for the browser. -func (a *Auth) CallBack() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - val, err := url.ParseQuery(r.URL.RawQuery) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - code := val.Get("code") - csrfToken, p, ok := strings.Cut(val.Get("state"), ",") - if !ok { - log.Error().Msg(fmt.Sprintf("invalid state: %s", val.Get("state"))) - http.Error( - w, - fmt.Sprintf("invalid state: %s", val.Get("state")), - http.StatusInternalServerError, - ) - return - } - - expectedCSRF, err := r.Cookie("csrf_token") - if err == http.ErrNoCookie { - http.Error(w, "no csrf cookie error", http.StatusUnauthorized) - return - } - if csrfToken != expectedCSRF.Value { - http.Error(w, "csrf error", http.StatusUnauthorized) - return - } - - provider, ok := a.Providers[strings.ToLower(p)] - if !ok { - http.Error(w, "auth provider not known", http.StatusUnauthorized) - return - } - - oauth2Token, err := provider.Exchange(r.Context(), code) - if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - return - } - - userID, userName, err := provider.GetIdentity(r.Context(), oauth2Token) - if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - return - } - - token, err := a.JWTSecret.GenerateToken(userID, userName, strings.ToLower(p)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - cookie := &http.Cookie{ - Name: jwt.TokenCookieKey, - Value: token, - Path: "/", - Expires: time.Now().Add(jwt.ExpiresDuration), - HttpOnly: true, - } - http.SetCookie(w, cookie) - http.Redirect(w, r, "/", http.StatusFound) - } -} - // Logout removes session cookies and redirect to home. func Logout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(jwt.TokenCookieKey) diff --git a/auth/auth_config.go b/auth/auth_config.go index 24af2e2..1082c99 100644 --- a/auth/auth_config.go +++ b/auth/auth_config.go @@ -5,57 +5,16 @@ import ( "fmt" "strings" + "github.com/Darkness4/auth-htmx/auth/oauth" "github.com/coreos/go-oidc/v3/oidc" - "github.com/golang-jwt/jwt/v5" "golang.org/x/oauth2" "golang.org/x/oauth2/github" ) // Config is the authentication configuration definition for the application. type Config struct { - Providers []ProviderConfig `yaml:"providers"` - SelfHostUsers bool `yaml:"selfHostUsers"` -} - -// ProviderConfig is the configuration of one provider to achieve the OAuth2 flow. -type ProviderConfig struct { - Type ProviderType `yaml:"type"` - Name string `yaml:"name"` - ClientID string `yaml:"clientID"` - ClientSecret string `yaml:"clientSecret"` - Endpoint string `yaml:"endpoint"` -} - -// ProviderType is a string uses the indentify edge cases in authentication. -type ProviderType string - -const ( - // ProviderGitHub is the type of the authentication provider that uses GitHub OAuth2. - ProviderGitHub ProviderType = "github" - // ProviderOIDC is the generic type of authentication provider that uses OIDC. - ProviderOIDC ProviderType = "oidc" -) - -// Provider is the interface that defines the necessary methods of authentication providers. -type Provider interface { - // AuthCodeURL returns the URL of the consent page that asks for permissions. - AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string - // Exchange converts a code into an OAuth2 token. - Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) - - // DisplayName is the provider's name that can be displayed publicly. - DisplayName() string - GetIdentity( - ctx context.Context, - token *oauth2.Token, - ) (userID string, userName string, err error) -} - -// OIDCClaims are the standard fields given by an OIDC provider. -type OIDCClaims struct { - jwt.RegisteredClaims - Name string `json:"name"` - Email string `json:"email"` + Providers []oauth.ProviderConfig `yaml:"providers"` + SelfHostUsers bool `yaml:"selfHostUsers"` } // GenerateProviders generates a map of provider based on the given configuration. @@ -63,12 +22,12 @@ func GenerateProviders( ctx context.Context, config Config, redirectURL string, -) (pp map[string]Provider, err error) { - pp = make(map[string]Provider) +) (pp map[string]oauth.Provider, err error) { + pp = make(map[string]oauth.Provider) for _, p := range config.Providers { switch p.Type { - case ProviderGitHub: - pp[strings.ToLower(p.Name)] = &GitHubProvider{ + case oauth.ProviderGitHub: + pp[strings.ToLower(p.Name)] = &oauth.GitHubProvider{ Name: p.Name, Config: &oauth2.Config{ ClientID: p.ClientID, @@ -78,12 +37,12 @@ func GenerateProviders( Scopes: []string{"read:user", "user:email"}, }, } - case ProviderOIDC: + case oauth.ProviderOIDC: provider, err := oidc.NewProvider(ctx, p.Endpoint) if err != nil { return pp, err } - pp[strings.ToLower(p.Name)] = &OIDCProvider{ + pp[strings.ToLower(p.Name)] = &oauth.OIDCProvider{ Name: p.Name, Config: &oauth2.Config{ ClientID: p.ClientID, diff --git a/auth/oauth/oauth.go b/auth/oauth/oauth.go new file mode 100644 index 0000000..691d53c --- /dev/null +++ b/auth/oauth/oauth.go @@ -0,0 +1,127 @@ +// Package auth defines the authentication layer of the application. +package oauth + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Darkness4/auth-htmx/jwt" + "github.com/gorilla/csrf" + "github.com/rs/zerolog/log" +) + +// OAuth is a service that provides HTTP handlers and middlewares used for authentication. +type OAuth struct { + JWTSecret jwt.Secret + Providers map[string]Provider +} + +// Login is the handler that redirect to the authentication page of the OAuth Provider. +func (a *OAuth) Login() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + val, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + p := val.Get("provider") + + provider, ok := a.Providers[strings.ToLower(p)] + if !ok { + http.Error(w, "auth provider not known", http.StatusUnauthorized) + return + } + + token := csrf.Token(r) + cookie := &http.Cookie{ + Name: "csrf_token", + Value: token, + Expires: time.Now().Add(1 * time.Minute), // Set expiration time as needed + HttpOnly: true, + } + // State contain the provider and the csrf token. + state := fmt.Sprintf("%s,%s", token, p) + http.SetCookie(w, cookie) + http.Redirect( + w, + r, + provider.AuthCodeURL(state), + http.StatusFound, + ) + } +} + +// CallBack is the handler called after login. +// +// It: +// +// 1. Fetches the accessToken +// 2. Fetches some user info and wrap them in a JWT token +// 3. Store the JWT token in a cookie for the browser. +func (a *OAuth) CallBack() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + val, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + code := val.Get("code") + csrfToken, p, ok := strings.Cut(val.Get("state"), ",") + if !ok { + log.Error().Msg(fmt.Sprintf("invalid state: %s", val.Get("state"))) + http.Error( + w, + fmt.Sprintf("invalid state: %s", val.Get("state")), + http.StatusInternalServerError, + ) + return + } + + expectedCSRF, err := r.Cookie("csrf_token") + if err == http.ErrNoCookie { + http.Error(w, "no csrf cookie error", http.StatusUnauthorized) + return + } + if csrfToken != expectedCSRF.Value { + http.Error(w, "csrf error", http.StatusUnauthorized) + return + } + + provider, ok := a.Providers[strings.ToLower(p)] + if !ok { + http.Error(w, "auth provider not known", http.StatusUnauthorized) + return + } + + oauth2Token, err := provider.Exchange(r.Context(), code) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + userID, userName, err := provider.GetIdentity(r.Context(), oauth2Token) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + token, err := a.JWTSecret.GenerateToken(userID, userName, strings.ToLower(p)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + cookie := &http.Cookie{ + Name: jwt.TokenCookieKey, + Value: token, + Path: "/", + Expires: time.Now().Add(jwt.ExpiresDuration), + HttpOnly: true, + } + http.SetCookie(w, cookie) + http.Redirect(w, r, "/", http.StatusFound) + } +} diff --git a/auth/auth_github_provider.go b/auth/oauth/oauth_github_provider.go similarity index 99% rename from auth/auth_github_provider.go rename to auth/oauth/oauth_github_provider.go index 49083e0..843a2f1 100644 --- a/auth/auth_github_provider.go +++ b/auth/oauth/oauth_github_provider.go @@ -1,4 +1,4 @@ -package auth +package oauth import ( "context" diff --git a/auth/auth_oidc_provider.go b/auth/oauth/oauth_oidc_provider.go similarity index 98% rename from auth/auth_oidc_provider.go rename to auth/oauth/oauth_oidc_provider.go index cbc0fb4..6fab748 100644 --- a/auth/auth_oidc_provider.go +++ b/auth/oauth/oauth_oidc_provider.go @@ -1,4 +1,4 @@ -package auth +package oauth import ( "context" diff --git a/auth/oauth/oauth_provider.go b/auth/oauth/oauth_provider.go new file mode 100644 index 0000000..ad59f7d --- /dev/null +++ b/auth/oauth/oauth_provider.go @@ -0,0 +1,49 @@ +package oauth + +import ( + "context" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2" +) + +// ProviderConfig is the configuration of one provider to achieve the OAuth2 flow. +type ProviderConfig struct { + Type ProviderType `yaml:"type"` + Name string `yaml:"name"` + ClientID string `yaml:"clientID"` + ClientSecret string `yaml:"clientSecret"` + Endpoint string `yaml:"endpoint"` +} + +// ProviderType is a string uses the indentify edge cases in authentication. +type ProviderType string + +const ( + // ProviderGitHub is the type of the authentication provider that uses GitHub OAuth2. + ProviderGitHub ProviderType = "github" + // ProviderOIDC is the generic type of authentication provider that uses OIDC. + ProviderOIDC ProviderType = "oidc" +) + +// Provider is the interface that defines the necessary methods of authentication providers. +type Provider interface { + // AuthCodeURL returns the URL of the consent page that asks for permissions. + AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string + // Exchange converts a code into an OAuth2 token. + Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) + + // DisplayName is the provider's name that can be displayed publicly. + DisplayName() string + GetIdentity( + ctx context.Context, + token *oauth2.Token, + ) (userID string, userName string, err error) +} + +// OIDCClaims are the standard fields given by an OIDC provider. +type OIDCClaims struct { + jwt.RegisteredClaims + Name string `json:"name"` + Email string `json:"email"` +} diff --git a/go.mod b/go.mod index a94e957..eff6bda 100644 --- a/go.mod +++ b/go.mod @@ -101,7 +101,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.0 // indirect github.com/google/s2a-go v0.1.7 // indirect - github.com/google/uuid v1.5.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect @@ -213,7 +213,7 @@ require ( modernc.org/fileutil v1.2.0 // indirect modernc.org/golex v1.1.0 // indirect modernc.org/internal v1.0.0 // indirect - modernc.org/libc v1.40.1 // indirect + modernc.org/libc v1.40.7 // indirect modernc.org/lldb v1.0.0 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.7.2 // indirect diff --git a/go.sum b/go.sum index 69f4c74..31ad9d6 100644 --- a/go.sum +++ b/go.sum @@ -296,8 +296,8 @@ github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= @@ -923,8 +923,8 @@ modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/internal v1.0.0 h1:XMDsFDcBDsibbBnHB2xzljZ+B1yrOVLEFkKL2u15Glw= modernc.org/internal v1.0.0/go.mod h1:VUD/+JAkhCpvkUitlEOnhpVxCgsBI90oTzSCRcqQVSM= -modernc.org/libc v1.40.1 h1:ZhRylEBcj3GyQbPVC8JxIg7SdrT4JOxIDJoUon0NfF8= -modernc.org/libc v1.40.1/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/libc v1.40.7 h1:oeLS0G067ZqUu+v143Dqad0btMfKmNS7SuOsnkq0Ysg= +modernc.org/libc v1.40.7/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= modernc.org/lldb v1.0.0 h1:6vjDJxQEfhlOLwl4bhpwIz00uyFK4EmSYcbwqwbynsc= modernc.org/lldb v1.0.0/go.mod h1:jcRvJGWfCGodDZz8BPwiKMJxGJngQ/5DrRapkQnLob8= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= diff --git a/main.go b/main.go index 94797e6..e7798ec 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "embed" "github.com/Darkness4/auth-htmx/auth" + "github.com/Darkness4/auth-htmx/auth/oauth" internalwebauthn "github.com/Darkness4/auth-htmx/auth/webauthn" "github.com/Darkness4/auth-htmx/auth/webauthn/session" "github.com/Darkness4/auth-htmx/database" @@ -124,7 +125,7 @@ var app = &cli.App{ } // Auth - authService := auth.Auth{ + authService := oauth.OAuth{ JWTSecret: jwt.Secret(jwtSecret), Providers: providers, } @@ -226,7 +227,7 @@ var app = &cli.App{ Provider string Credentials []webauthn.Credential CSRFToken string - Providers map[string]auth.Provider + Providers map[string]oauth.Provider SelfHostUsers bool }{ UserName: claims.Subject,