Reset
This commit is contained in:
142
security/oidc.go
Normal file
142
security/oidc.go
Normal file
@ -0,0 +1,142 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OIDCConfig is the configuration for OIDC authentication
|
||||
type OIDCConfig struct {
|
||||
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
|
||||
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
|
||||
ClientID string `yaml:"client-id"`
|
||||
ClientSecret string `yaml:"client-secret"`
|
||||
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
|
||||
|
||||
oauth2Config oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// isValid returns whether the basic security configuration is valid or not
|
||||
func (c *OIDCConfig) isValid() bool {
|
||||
return len(c.IssuerURL) > 0 && len(c.RedirectURL) > 0 && strings.HasSuffix(c.RedirectURL, "/authorization-code/callback") && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) initialize() error {
|
||||
provider, err := oidc.NewProvider(context.Background(), c.IssuerURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.verifier = provider.Verifier(&oidc.Config{ClientID: c.ClientID})
|
||||
// Configure an OpenID Connect aware OAuth2 client.
|
||||
c.oauth2Config = oauth2.Config{
|
||||
ClientID: c.ClientID,
|
||||
ClientSecret: c.ClientSecret,
|
||||
Scopes: c.Scopes,
|
||||
RedirectURL: c.RedirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) loginHandler(ctx *fiber.Ctx) error {
|
||||
state, nonce := uuid.NewString(), uuid.NewString()
|
||||
ctx.Cookie(&fiber.Cookie{
|
||||
Name: cookieNameState,
|
||||
Value: state,
|
||||
Path: "/",
|
||||
MaxAge: int(time.Hour.Seconds()),
|
||||
SameSite: "lax",
|
||||
HTTPOnly: true,
|
||||
})
|
||||
ctx.Cookie(&fiber.Cookie{
|
||||
Name: cookieNameNonce,
|
||||
Value: nonce,
|
||||
Path: "/",
|
||||
MaxAge: int(time.Hour.Seconds()),
|
||||
SameSite: "lax",
|
||||
HTTPOnly: true,
|
||||
})
|
||||
return ctx.Redirect(c.oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { // TODO: Migrate to a native fiber handler
|
||||
// Check if there's an error
|
||||
if len(r.URL.Query().Get("error")) > 0 {
|
||||
http.Error(w, r.URL.Query().Get("error")+": "+r.URL.Query().Get("error_description"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Ensure that the state has the expected value
|
||||
state, err := r.Cookie(cookieNameState)
|
||||
if err != nil {
|
||||
http.Error(w, "state not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("state") != state.Value {
|
||||
http.Error(w, "state did not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Validate token
|
||||
oauth2Token, err := c.oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||
if err != nil {
|
||||
http.Error(w, "Error exchanging token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
http.Error(w, "Missing 'id_token' in oauth2 token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
idToken, err := c.verifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to verify id_token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Validate nonce
|
||||
nonce, err := r.Cookie(cookieNameNonce)
|
||||
if err != nil {
|
||||
http.Error(w, "nonce not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if idToken.Nonce != nonce.Value {
|
||||
http.Error(w, "nonce did not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(c.AllowedSubjects) == 0 {
|
||||
// If there's no allowed subjects, all subjects are allowed.
|
||||
c.setSessionCookie(w, idToken)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
for _, subject := range c.AllowedSubjects {
|
||||
if strings.ToLower(subject) == strings.ToLower(idToken.Subject) {
|
||||
c.setSessionCookie(w, idToken)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
logr.Debugf("[security.callbackHandler] Subject %s is not in the list of allowed subjects", idToken.Subject)
|
||||
http.Redirect(w, r, "/?error=access_denied", http.StatusFound)
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDToken) {
|
||||
// At this point, the user has been confirmed. All that's left to do is create a session.
|
||||
sessionID := uuid.NewString()
|
||||
sessions.SetWithTTL(sessionID, idToken.Subject, time.Hour)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieNameSession,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: int(time.Hour.Seconds()),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user