diff --git a/go.mod b/go.mod
index fe2c17ce..81b74fd5 100644
--- a/go.mod
+++ b/go.mod
@@ -5,20 +5,23 @@ go 1.19
require (
github.com/99designs/gqlgen v0.17.24
github.com/TJM/gin-gonic-oidcauth v0.3.0
+ github.com/franela/goblin v0.0.0-20210113153425-413781f5e6c8
github.com/gin-contrib/sessions v0.0.5
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.8.2
github.com/google/uuid v1.3.0
+ github.com/onsi/gomega v1.5.0
github.com/spf13/cobra v1.6.1
github.com/spf13/viper v1.15.0
github.com/vektah/gqlparser/v2 v2.5.1
+ golang.org/x/exp v0.0.0-20230118134722-a68e582fa157
)
require (
github.com/agnivade/levenshtein v1.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
- github.com/coreos/go-oidc/v3 v3.5.0 // indirect
+ github.com/coreos/go-oidc/v3 v3.5.0
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
@@ -40,7 +43,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
- github.com/letsencrypt/boulder v0.0.0-20230130081212-d2d9078213db // indirect
+ github.com/letsencrypt/boulder v0.0.0-20230130081212-d2d9078213db
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
@@ -69,8 +72,8 @@ require (
go.opentelemetry.io/otel/trace v1.13.0
golang.org/x/crypto v0.6.0 // indirect
golang.org/x/mod v0.6.0 // indirect
- golang.org/x/net v0.7.0 // indirect
- golang.org/x/oauth2 v0.3.0 // indirect
+ golang.org/x/net v0.7.0
+ golang.org/x/oauth2 v0.3.0
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/tools v0.2.0 // indirect
diff --git a/go.sum b/go.sum
index 87ba0e23..5a60a94d 100644
--- a/go.sum
+++ b/go.sum
@@ -245,6 +245,7 @@ github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+l
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@@ -326,6 +327,7 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@@ -478,6 +480,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
+golang.org/x/exp v0.0.0-20230118134722-a68e582fa157 h1:fiNkyhJPUvxbRPbCqY/D9qdjmPzfHcpK3P4bM4gioSY=
+golang.org/x/exp v0.0.0-20230118134722-a68e582fa157/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -817,6 +821,7 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
@@ -824,6 +829,7 @@ gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/square/go-jose.v2 v2.4.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/pkg/auth/config.go b/pkg/auth/config.go
new file mode 100644
index 00000000..9d859e27
--- /dev/null
+++ b/pkg/auth/config.go
@@ -0,0 +1,154 @@
+package auth
+
+import (
+ "errors"
+ "log"
+ "os"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+)
+
+// Config represents available options for oidcauth.
+type Config struct {
+ // ClientID is the OAUTH2 Client ID
+ // Default value is: (read from OS ENV: OAUTH2_CLIENT_ID)
+ ClientID string
+
+ // ClientSecret is the OAUTH2 Client Secret
+ // Default value is: (read from OS ENV: OAUTH2_CLIENT_SECRET)
+ ClientSecret string
+
+ // IssuerURL is the root URL to theIdentity Provider
+ // Default value is: (read from OS ENV: OIDC_ISSUER_URL)
+ IssuerURL string
+
+ // RedirectURL is the path that the Identity Provider will redirect clients to
+ // Default value is: (read from OS ENV: OIDC_REDIRECT_URL)
+ RedirectURL string
+
+ // Scopes is a list of OIDC Scopes to request.
+ // Default value is: []string{oidc.ScopeOpenID, "profile", "email"}
+ Scopes []string
+
+ // LoginClaim is the OIDC claim to map to the user's login (username)
+ // Default value is: "email"
+ LoginClaim string
+
+ // SessionClaims is the list of OIDC claims to add to the user's session (in addition to LoginClaim)
+ // Example []string{"email", "givenName", "name"}
+ // NOTE: This can be set to ["*"] to load *all* claims. (nonce will be excluded)
+ // Default value is: ["*"]
+ SessionClaims []string
+
+ // SessionPrefix is an optional prefix string to prefix to the claims (i.e. google: or corp:) to prevent
+ // clashes in the session namespace
+ // Default value is: ""
+ SessionPrefix string
+
+ // DefaultAuthenticatedURL is the URL to redirect a user to after successful authentication. By default, we will
+ // try to determine where they were when they requested to login and send them back there.
+ // Default value is: "/"
+ DefaultAuthenticatedURL string
+
+ // LogoutURL is the URL to redirect a user to after logging out.
+ // NOTE: If you require / to be authenticated, setting this to / will start the login process immediately, which may not be desirable.
+ // Default value is: "/"
+ LogoutURL string
+}
+
+// DefaultConfig will create a new config object with defaults
+// NOTE: This matches the examples on https://github.com/coreos/go-oidc/tree/v3/example
+func DefaultConfig() (c *Config) {
+ c = &Config{
+ ClientID: os.Getenv("OIDC_CLIENT_ID"),
+ ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
+ IssuerURL: os.Getenv("OIDC_ISSUER_URL"),
+ RedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
+ Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
+ LoginClaim: "email",
+ SessionClaims: []string{"*"},
+ DefaultAuthenticatedURL: "/",
+ LogoutURL: "/",
+ }
+ return
+}
+
+// ExampleConfigDex will return the config for a default DEX IdP example-app
+// DEX: https://github.com/dexidp/dex
+func ExampleConfigDex() (c *Config) {
+ c = DefaultConfig()
+ c.ClientID = "example-app"
+ c.ClientSecret = "ZXhhbXBsZS1hcHAtc2VjcmV0"
+ c.IssuerURL = "http://127.0.0.1:5556/dex"
+ c.RedirectURL = "http://127.0.0.1:5555/callback"
+ return
+}
+
+// ExampleConfigGoogle will return the config for the Google Accounts IdP like the go-oidc examples
+// go-oidc google example: https://github.com/coreos/go-oidc/tree/v3/example
+func ExampleConfigGoogle() (c *Config) {
+ c = DefaultConfig()
+ c.ClientID = os.Getenv("GOOGLE_OAUTH2_CLIENT_ID")
+ c.ClientSecret = os.Getenv("GOOGLE_OAUTH2_CLIENT_SECRET")
+ c.IssuerURL = "https://accounts.google.com"
+ c.RedirectURL = "http://127.0.0.1:5556/auth/google/callback"
+ return
+}
+
+// Validate will validate the Config
+func (c Config) Validate() (err error) {
+
+ if c.ClientID == "" {
+ err = errors.New("ClientID is required")
+ return
+ }
+
+ if c.ClientSecret == "" {
+ err = errors.New("ClientSecret is required")
+ return
+ }
+
+ if c.IssuerURL == "" { // TODO: Validate that its a properly formed URL
+ err = errors.New("IssuerURL is required")
+ return
+ }
+
+ if c.RedirectURL == "" { // TODO: Validate that its a properly formed URL
+ err = errors.New("RedirectURL is required")
+ return
+ }
+
+ return
+}
+
+// GetOidcAuth returns the configured OIDC authentication controller
+func GetOidcAuth(c *Config) (o *OidcAuth, err error) {
+ return c.GetOidcAuth()
+}
+
+// GetOidcAuth returns the configured OIDC authentication controller
+func (c *Config) GetOidcAuth() (o *OidcAuth, err error) {
+ err = c.Validate()
+ if err != nil {
+ log.Fatal(err)
+ }
+ return newOidcAuth(c)
+}
+
+// The methods below can be used to return the middleware, but currently do
+// not handle the routes. They are of limited use, for now.
+//
+// // Default returns the location middleware with default configuration.
+// func Default() gin.HandlerFunc {
+// config := DefaultConfig()
+// return New(config)
+// }
+
+// // New returns the location middleware with user-defined custom configuration.
+// func New(c *Config) gin.HandlerFunc {
+// auth, err := c.GetOidcAuth()
+// if err != nil {
+// log.Fatal("[oidcauth] Error getting auth handler")
+// }
+// return auth.AuthRequired()
+// }
diff --git a/pkg/auth/config_test.go b/pkg/auth/config_test.go
new file mode 100644
index 00000000..af449daf
--- /dev/null
+++ b/pkg/auth/config_test.go
@@ -0,0 +1,104 @@
+package auth
+
+import (
+ "os"
+ "testing"
+
+ goblin "github.com/franela/goblin"
+ . "github.com/onsi/gomega"
+)
+
+func TestConfig(t *testing.T) {
+ g := goblin.Goblin(t)
+
+ //special hook for gomega
+ RegisterFailHandler(func(m string, _ ...int) { g.Fail(m) })
+
+ g.Describe("TestConfig", func() {
+
+ g.Describe("DefaultConfig", func() {
+ os.Setenv("OIDC_CLIENT_ID", "client-id")
+ os.Setenv("OIDC_CLIENT_SECRET", "client-secret")
+ os.Setenv("OIDC_ISSUER_URL", "issuer-url")
+ os.Setenv("OIDC_REDIRECT_URL", "redirect-url")
+ c := DefaultConfig()
+
+ g.It("should retrieve values from env", func() {
+ Expect(c.ClientID).To(BeEquivalentTo("client-id"))
+ Expect(c.ClientSecret).To(BeEquivalentTo("client-secret"))
+ Expect(c.IssuerURL).To(BeEquivalentTo("issuer-url"))
+ Expect(c.RedirectURL).To(BeEquivalentTo("redirect-url"))
+ })
+ })
+
+ g.Describe("ExampleConfigDex", func() {
+ c := ExampleConfigDex()
+
+ g.It("should match dex example-app config", func() {
+ Expect(c.ClientID).To(BeEquivalentTo("example-app"))
+ Expect(c.ClientSecret).To(BeEquivalentTo("ZXhhbXBsZS1hcHAtc2VjcmV0"))
+ Expect(c.IssuerURL).To(BeEquivalentTo("http://127.0.0.1:5556/dex"))
+ Expect(c.RedirectURL).To(BeEquivalentTo("http://127.0.0.1:5555/callback"))
+ })
+ })
+
+ g.Describe("ExampleConfigGoogle", func() {
+ os.Setenv("GOOGLE_OAUTH2_CLIENT_ID", "client-id")
+ os.Setenv("GOOGLE_OAUTH2_CLIENT_SECRET", "client-secret")
+ c := ExampleConfigGoogle()
+
+ g.It("should match example google config", func() {
+ Expect(c.ClientID).To(BeEquivalentTo("client-id"))
+ Expect(c.ClientSecret).To(BeEquivalentTo("client-secret"))
+ Expect(c.IssuerURL).To(BeEquivalentTo("https://accounts.google.com"))
+ Expect(c.RedirectURL).To(BeEquivalentTo("http://127.0.0.1:5556/auth/google/callback"))
+ })
+ })
+
+ g.Describe("Validate", func() {
+ c := ExampleConfigDex()
+ c.ClientID = ""
+ g.It("should error on empty ClientID", func() {
+ Expect(c.Validate()).ToNot(BeNil())
+ })
+
+ c = ExampleConfigDex()
+ c.ClientSecret = ""
+ g.It("should error on empty ClientSecret", func() {
+ Expect(c.Validate()).ToNot(BeNil())
+ })
+
+ c = ExampleConfigDex()
+ c.IssuerURL = ""
+ g.It("should error on empty IssuerURL", func() {
+ Expect(c.Validate()).ToNot(BeNil())
+ })
+
+ c = ExampleConfigDex()
+ c.RedirectURL = ""
+ g.It("should error on empty RedirectURL", func() {
+ Expect(c.Validate()).ToNot(BeNil())
+ })
+ })
+
+ // g.Describe("GetOidcAuth", func() {
+ // auth, err := GetOidcAuth(ExampleConfigDex())
+
+ // g.It("should work", func() {
+ // Expect(auth).NotTo(BeNil())
+ // Expect(err).To(BeNil())
+ // })
+ // })
+
+ // g.Describe("c.GetOidcAuth", func() {
+ // c := ExampleConfigDex()
+ // auth, err := c.GetOidcAuth()
+
+ // g.It("should work", func() {
+ // Expect(auth).NotTo(BeNil())
+ // Expect(err).To(BeNil())
+ // })
+ // })
+
+ })
+}
diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go
new file mode 100644
index 00000000..79d5efaa
--- /dev/null
+++ b/pkg/auth/oidc.go
@@ -0,0 +1,339 @@
+package auth
+
+import (
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+ "github.com/letsencrypt/boulder/metrics"
+ "github.com/letsencrypt/boulder/nonce"
+
+ "golang.org/x/exp/slog"
+ "golang.org/x/net/context"
+ "golang.org/x/oauth2"
+)
+
+const (
+ // oidcStateSessionKey is used to validate callback from client, see: https://auth0.com/docs/protocols/state-parameters
+ oidcStateSessionKey string = "auth:state"
+
+ // previousURLSessionKey will temporarily hold the URL path that the user was at before authentication started
+ previousURLSessionKey string = "auth:PreviousURL"
+
+ // accessTokenSessionKey is the session key to hold the oauth access token
+ accessTokenSessionKey string = "auth:AccessToken"
+
+ // loginSessionKey is the session key to hold the "login" (username)
+ loginSessionKey string = "auth:login"
+
+ // expirationSessionKey is the when the session is expired (in unixtime as an uint)
+ expirationSessionKey string = "auth:sessionExpiration"
+
+ // AuthUserKey stores the authenticated user's login (username or email) in this context key
+ AuthUserKey string = "user"
+)
+
+// OidcAuth handles OIDC Authentication
+type OidcAuth struct {
+ ctx context.Context
+ provider *oidc.Provider
+ verifier *oidc.IDTokenVerifier
+ oauth2Config *oauth2.Config
+ nonceService *nonce.NonceService
+ config *Config
+ Debug bool // DUMP oidc paramters as JSON instead of redirecting
+}
+
+// newOidcAuth returns the oidcAuth struct, expects config to have been validated
+func newOidcAuth(c *Config) (o *OidcAuth, err error) {
+ o = new(OidcAuth)
+
+ o.ctx = context.Background()
+
+ provider, err := oidc.NewProvider(o.ctx, c.IssuerURL)
+ if err != nil {
+ slog.Error("cannot create oidc provider", err)
+ }
+ o.provider = provider
+
+ oidcConfig := &oidc.Config{
+ ClientID: c.ClientID,
+ }
+ // Use the nonce source to create a custom ID Token verifier.
+ o.verifier = o.provider.Verifier(oidcConfig)
+
+ o.oauth2Config = &oauth2.Config{
+ ClientID: c.ClientID,
+ ClientSecret: c.ClientSecret,
+ Endpoint: provider.Endpoint(),
+ RedirectURL: c.RedirectURL,
+ Scopes: c.Scopes,
+ }
+
+ ns, err := nonce.NewNonceService(metrics.NoopRegisterer, 0, "oidc")
+ if err != nil {
+ slog.Error("cannot create nonce service", err)
+ }
+ o.nonceService = ns
+
+ o.config = c // Save Config
+ return
+}
+
+// AuthRequired middleware requires OIDC authentication
+// BE CAREFUL Adding this to / (or the top level router)
+func (o *OidcAuth) AuthRequired() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ session := sessions.Default(c)
+ e := session.Get(expirationSessionKey)
+ l := session.Get(loginSessionKey)
+ if l == nil || e == nil {
+ o.doAuthentication(c)
+ c.Abort()
+ return
+ }
+
+ login := l.(string)
+ exp := time.Unix(int64(e.(float64)), 0) // e (float64) -> int64 -> unixtime -> time.Time
+ now := time.Now()
+
+ if now.After(exp) {
+ slog.Info("session expired", slog.String("login", login), slog.Time("exp", exp))
+ o.doAuthentication(c)
+ c.Abort()
+ return
+ }
+ // The user credentials was found, set user's loginClaim to key AuthUserKey in this context, the user's id can be read later using
+ // c.MustGet(oidcauth.AuthUserKey).
+ c.Set(AuthUserKey, login)
+ c.Next()
+ }
+}
+
+// AuthRequired middleware requires OIDC authentication
+// BE CAREFUL Adding this to / (or the top level router)
+func (o *OidcAuth) AuthRequiredWithoutRedirect() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ session := sessions.Default(c)
+ e := session.Get(expirationSessionKey)
+ l := session.Get(loginSessionKey)
+ if l == nil || e == nil {
+ c.AbortWithStatus(http.StatusUnauthorized)
+ return
+ }
+
+ login := l.(string)
+ exp := time.Unix(int64(e.(float64)), 0) // e (float64) -> int64 -> unixtime -> time.Time
+ now := time.Now()
+
+ if now.After(exp) {
+ slog.Info("session expired", slog.String("login", login), slog.Time("exp", exp))
+ c.AbortWithStatus(http.StatusUnauthorized)
+ return
+ }
+ // The user credentials was found, set user's loginClaim to key AuthUserKey in this context, the user's id can be read later using
+ // c.MustGet(oidcauth.AuthUserKey).
+ c.Set(AuthUserKey, login)
+ c.Next()
+ }
+}
+
+// Login will setup the appropriate state and redirect the user to the authentication provider
+func (o *OidcAuth) Login(c *gin.Context) {
+ state := o.generateState(c)
+ nonce := o.generateNonce(c)
+ session := sessions.Default(c)
+ session.Set(oidcStateSessionKey, state)
+ err := session.Save()
+ if err != nil {
+ c.AbortWithError(http.StatusInternalServerError, errors.New("Error saving session: "+err.Error()))
+ return
+ }
+ c.Redirect(http.StatusFound, o.oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)))
+}
+
+// Logout will clear the session
+// NOTE: It will not invalidate the OIDC session (Not SSO)
+func (o *OidcAuth) Logout(c *gin.Context) {
+ session := sessions.Default(c)
+ // These Sets will mark the session as "written" and clear the values (jic)
+ // session.Set(accessTokenSessionKey, nil)
+ session.Set(loginSessionKey, nil)
+ session.Clear()
+ session.Options(sessions.Options{Path: "/", MaxAge: -1}) // this sets the cookie as expired
+ session.Save()
+ c.Redirect(http.StatusTemporaryRedirect, o.config.LogoutURL)
+}
+
+// AuthCallback will handle the authentication callback (redirect) from the Identity Provider
+//
+// This is the part that actually "does" the authentication.
+func (o *OidcAuth) AuthCallback(c *gin.Context) {
+ sessionState, err := o.getState(c)
+ if err != nil {
+ slog.Error("cannot retrieve state", err)
+ c.AbortWithError(http.StatusBadRequest, errors.New("[auth] unable to retrieve state: "+err.Error()))
+ return
+ }
+ if c.Query("state") != sessionState {
+ slog.Error("auth state did not macht", err, slog.String("queryState", c.Query("state")), slog.String("sessionState", sessionState))
+ c.AbortWithError(http.StatusBadRequest, errors.New("[auth] state did not match"))
+ return
+ }
+
+ oauth2Token, err := o.oauth2Config.Exchange(o.ctx, c.Query("code"))
+ if err != nil {
+ c.AbortWithError(http.StatusInternalServerError, errors.New("[auth] Failed to exchange token: "+err.Error()))
+ return
+ }
+
+ rawIDToken, ok := oauth2Token.Extra("id_token").(string)
+ if !ok {
+ c.AbortWithError(http.StatusInternalServerError, errors.New("[auth] No id_token field in oauth2 token"))
+ return
+ }
+
+ // Verify the ID Token signature and nonce.
+ idToken, err := o.verifier.Verify(o.ctx, rawIDToken)
+ if err != nil {
+ c.AbortWithError(http.StatusInternalServerError, errors.New("[auth] Failed to verify ID Token: "+err.Error()))
+ return
+ }
+ if !o.nonceService.Valid(idToken.Nonce) {
+ c.AbortWithError(http.StatusInternalServerError, errors.New("[auth] Invalid ID Token nonce"))
+ return
+ }
+
+ // IDTokenClaims := new(json.RawMessage) // ID Token payload is just JSON.
+ claims := make(map[string]interface{})
+ if err := idToken.Claims(&claims); err != nil {
+ c.AbortWithError(http.StatusInternalServerError, errors.New("[auth] Failed retrieve claims: "+err.Error()))
+ return
+ }
+
+ // Save to session
+ session := sessions.Default(c)
+ session.AddFlash("Authentication Successful!")
+
+ // Process Results - just dump everything into the session for now (probably not a good idea)
+ // session.Set(accessTokenSessionKey, oauth2Token.AccessToken) // sessions doesn't like very long AccessToken
+ // session.Set("TokenType", oauth2Token.TokenType) // Not Needed?
+ // session.Set("Expiry", oauth2Token.Expiry) // sessions doesn't like time.Time
+ delete(claims, "nonce") // No longer useful
+
+ // Add claims to session
+ if len(o.config.SessionClaims) > 0 {
+ if o.config.SessionClaims[0] == "*" { // Set All Claims in Session
+ for claim, val := range claims {
+ sessionKey := o.config.SessionPrefix + claim
+ session.Set(sessionKey, val)
+ }
+ } else {
+ for _, sessionClaim := range o.config.SessionClaims {
+ if val, ok := claims[sessionClaim]; ok {
+ sessionKey := o.config.SessionPrefix + sessionClaim
+ session.Set(sessionKey, val)
+ }
+ }
+ }
+ }
+
+ // Set login in session
+ if login, ok := claims[o.config.LoginClaim]; ok {
+ session.Set(loginSessionKey, login)
+ }
+
+ // Set expiration in session
+ if exp, ok := claims["exp"]; ok {
+ session.Set(expirationSessionKey, exp)
+ }
+
+ redirectURL := o.config.DefaultAuthenticatedURL
+ u := session.Get(previousURLSessionKey)
+ if u != nil {
+ redirectURL = u.(string)
+ session.Delete(previousURLSessionKey)
+ }
+
+ err = session.Save()
+ if err != nil {
+ c.AbortWithError(http.StatusInternalServerError, errors.New("Error saving session: "+err.Error()))
+ return
+ }
+
+ if o.Debug {
+ c.JSON(http.StatusOK, gin.H{
+ "redirectURL": redirectURL,
+ "rawIDToken": rawIDToken,
+ "idToken": idToken,
+ "oauth2Token": oauth2Token,
+ "claims": claims,
+ })
+ return
+ }
+ c.Redirect(http.StatusFound, redirectURL)
+}
+
+// getState will return the state string (and/or err) from the session
+// NOTE: state is a string that is passed to the authentication provider, and returned to validate we sent the reqest.
+func (o *OidcAuth) getState(c *gin.Context) (state string, err error) {
+ session := sessions.Default(c)
+ s := session.Get(oidcStateSessionKey)
+ session.Delete(oidcStateSessionKey)
+ session.Save()
+ if s == nil {
+ err = errors.New("state was not found in session")
+ slog.Error("no state found", err)
+ return
+ }
+ if !o.nonceService.Valid(s.(string)) {
+ err = errors.New("state was not a valid nonce")
+ slog.Error("invalid nonce", err)
+ return
+ }
+ state = s.(string)
+ return
+
+}
+
+// generateState will generate the random string to be used for "state" in the oidc requests
+//
+// Opaque value used to maintain state between the request and the callback.
+// Typically, Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically
+// binding the value of this parameter with a browser cookie.
+func (o *OidcAuth) generateState(c *gin.Context) (state string) {
+ return o.generateNonce(c) // just use a nonce for now
+}
+
+// generateNonce will generate a nonce (one time use, random string), aborts on error
+func (o *OidcAuth) generateNonce(c *gin.Context) (nonce string) {
+ nonce, err := o.nonceService.Nonce()
+ if err != nil {
+ c.AbortWithError(http.StatusInternalServerError, errors.New("Error getting nonce: "+err.Error()))
+ }
+ return
+}
+
+// doAuthentication is designed to be called from middleware when it determines
+//
+// that the user is not authenticated. It will attempt to return the user to
+// the path they were requesting when authentication was required.
+func (o *OidcAuth) doAuthentication(c *gin.Context) {
+ session := sessions.Default(c)
+ previousURL := c.Request.RequestURI // Current URL
+ if previousURL == "" {
+ previousURL = o.config.DefaultAuthenticatedURL
+ }
+ session.Set(previousURLSessionKey, c.Request.RequestURI)
+ err := session.Save()
+ if err != nil {
+ slog.Error("cannot save session", err)
+ c.AbortWithError(http.StatusInternalServerError, err)
+ }
+
+ o.Login(c)
+ return
+}
diff --git a/pkg/config/auth.go b/pkg/config/auth.go
index 3076d9fa..4ee96364 100644
--- a/pkg/config/auth.go
+++ b/pkg/config/auth.go
@@ -3,7 +3,7 @@ package config
import (
"fmt"
- oidcauth "github.com/TJM/gin-gonic-oidcauth"
+ oidcauth "github.com/RedGecko/sitrep/pkg/auth"
"github.com/spf13/viper"
)
diff --git a/pkg/router/router.go b/pkg/router/router.go
index 9f7caf8c..cb7a1021 100644
--- a/pkg/router/router.go
+++ b/pkg/router/router.go
@@ -9,9 +9,9 @@ import (
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/RedGecko/sitrep/graph"
+ oidcauth "github.com/RedGecko/sitrep/pkg/auth"
"github.com/RedGecko/sitrep/pkg/config"
"github.com/RedGecko/sitrep/ui"
- oidcauth "github.com/TJM/gin-gonic-oidcauth"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-contrib/static"
@@ -86,10 +86,10 @@ func CreateRouter() (*gin.Engine, error) {
oidc.GET("/sign_in", auth.Login)
oidc.GET("/callback", auth.AuthCallback)
oidc.GET("/sign_out", auth.Logout)
- oidc.GET("/userinfo", auth.AuthRequired(), userInfoHandler())
+ oidc.GET("/userinfo", auth.AuthRequiredWithoutRedirect(), userInfoHandler())
oidc.GET("/index.html", auth.AuthRequired())
- api := r.Group("/api/v1", auth.AuthRequired())
+ api := r.Group("/api/v1", auth.AuthRequiredWithoutRedirect())
api.POST("/graphql", graphqlHandler())
admin := r.Group("/admin", auth.AuthRequired())
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 84635761..3a8571e3 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -187,7 +187,7 @@ function App() {
/>
- } />
+ } />