Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+293
View File
@@ -0,0 +1,293 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
const (
defaultFlowLifetime = 10 * time.Minute
)
type OIDCConfig struct {
Issuer string
ClientID string
ClientSecret string
RedirectURL string
Scopes []string
}
type Authorization struct {
URL string
State string
Nonce string
CodeVerifier string
ExpiresAt time.Time
}
func BeginAuthorization(endpoint oauth2.Endpoint, config OIDCConfig, now time.Time) (Authorization, error) {
if endpoint.AuthURL == "" || config.ClientID == "" || config.RedirectURL == "" {
return Authorization{}, errors.New("OIDC authorization configuration is incomplete")
}
state, err := randomToken()
if err != nil {
return Authorization{}, errors.New("generate authorization state")
}
nonce, err := randomToken()
if err != nil {
return Authorization{}, errors.New("generate authorization nonce")
}
verifier, err := randomToken()
if err != nil {
return Authorization{}, errors.New("generate PKCE verifier")
}
scopes := config.Scopes
if len(scopes) == 0 {
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
}
oauthConfig := oauth2.Config{
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
Endpoint: endpoint,
RedirectURL: config.RedirectURL,
Scopes: scopes,
}
authURL := oauthConfig.AuthCodeURL(state,
oauth2.SetAuthURLParam("nonce", nonce),
oauth2.SetAuthURLParam("code_challenge", pkceChallenge(verifier)),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
return Authorization{URL: authURL, State: state, Nonce: nonce, CodeVerifier: verifier, ExpiresAt: now.Add(defaultFlowLifetime)}, nil
}
func ValidateCallback(flow Authorization, state, code string, now time.Time) error {
if flow.State == "" || subtle.ConstantTimeCompare([]byte(flow.State), []byte(state)) != 1 {
return errors.New("OIDC state validation failed")
}
if flow.CodeVerifier == "" || flow.Nonce == "" {
return errors.New("OIDC flow is incomplete")
}
if now.After(flow.ExpiresAt) {
return errors.New("OIDC authorization expired")
}
if strings.TrimSpace(code) == "" {
return errors.New("OIDC authorization code is required")
}
return nil
}
func Exchange(ctx context.Context, flow Authorization, config OIDCConfig, endpoint oauth2.Endpoint, state, code string) (*oauth2.Token, error) {
if err := ValidateCallback(flow, state, code, time.Now()); err != nil {
return nil, err
}
oauthConfig := oauth2.Config{ClientID: config.ClientID, ClientSecret: config.ClientSecret, Endpoint: endpoint, RedirectURL: config.RedirectURL}
return oauthConfig.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", flow.CodeVerifier))
}
// Discovery is the provider metadata required to run one authorization code flow:
// the authorization/token endpoints for BeginAuthorization and Exchange, and the
// ID token verifier for VerifyIDToken. Resolve it once and reuse it.
type Discovery struct {
Endpoint oauth2.Endpoint
Verifier *oidc.IDTokenVerifier
}
func Discover(ctx context.Context, config OIDCConfig) (Discovery, error) {
if config.Issuer == "" || config.ClientID == "" {
return Discovery{}, errors.New("OIDC issuer and client ID are required")
}
provider, err := oidc.NewProvider(ctx, config.Issuer)
if err != nil {
return Discovery{}, fmt.Errorf("OIDC discovery failed")
}
return Discovery{Endpoint: provider.Endpoint(), Verifier: provider.Verifier(&oidc.Config{ClientID: config.ClientID})}, nil
}
func NewVerifier(ctx context.Context, config OIDCConfig) (*oidc.IDTokenVerifier, error) {
discovery, err := Discover(ctx, config)
if err != nil {
return nil, err
}
return discovery.Verifier, nil
}
func VerifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, rawToken, expectedNonce string) (*oidc.IDToken, error) {
if verifier == nil || strings.TrimSpace(rawToken) == "" || expectedNonce == "" {
return nil, errors.New("OIDC token verification input is incomplete")
}
token, err := verifier.Verify(ctx, rawToken)
if err != nil {
return nil, errors.New("OIDC token verification failed")
}
var claims struct {
Nonce string `json:"nonce"`
}
if err := token.Claims(&claims); err != nil || subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(expectedNonce)) != 1 {
return nil, errors.New("OIDC nonce validation failed")
}
return token, nil
}
const (
defaultGroupsClaim = "groups"
maxIdentityGroups = 128
)
// Identity is the bounded subset of verified ID token claims Pulse consumes.
type Identity struct {
Subject string
Groups []string
}
// ExtractIdentity reads the subject and the configured role claim from an already
// verified ID token. The claim may be a list of strings or a single string; values
// are trimmed, empty values dropped and the list bounded.
func ExtractIdentity(token *oidc.IDToken, groupsClaim string) (Identity, error) {
if token == nil {
return Identity{}, errors.New("OIDC identity token is required")
}
if groupsClaim == "" {
groupsClaim = defaultGroupsClaim
}
subject := strings.TrimSpace(token.Subject)
if subject == "" {
return Identity{}, errors.New("OIDC subject claim is required")
}
var claims map[string]json.RawMessage
if err := token.Claims(&claims); err != nil {
return Identity{}, errors.New("OIDC claims could not be read")
}
raw, ok := claims[groupsClaim]
if !ok {
return Identity{Subject: subject}, nil
}
groups, err := normalizeGroupClaim(raw)
if err != nil {
return Identity{}, err
}
return Identity{Subject: subject, Groups: groups}, nil
}
func normalizeGroupClaim(raw json.RawMessage) ([]string, error) {
var values []string
if err := json.Unmarshal(raw, &values); err != nil {
var single string
if err := json.Unmarshal(raw, &single); err != nil {
return nil, errors.New("OIDC role claim is malformed")
}
values = []string{single}
}
groups := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" || len(groups) >= maxIdentityGroups {
continue
}
groups = append(groups, trimmed)
}
return groups, nil
}
func randomToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
func pkceChallenge(verifier string) string {
digest := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(digest[:])
}
type Role string
const (
RoleViewer Role = "viewer"
RoleOperator Role = "operator"
RoleEditor Role = "editor"
RoleAdministrator Role = "administrator"
)
type Permission string
const (
PermissionView Permission = "view"
PermissionOperate Permission = "operate"
PermissionEdit Permission = "edit"
PermissionAdmin Permission = "admin"
)
type Principal struct {
Subject string
Role Role
}
func MapRoles(claims []string, mapping map[string]Role) (Role, error) {
priority := map[Role]int{RoleViewer: 1, RoleOperator: 2, RoleEditor: 3, RoleAdministrator: 4}
var selected Role
for _, claim := range claims {
role, ok := mapping[claim]
if !ok || priority[role] <= priority[selected] {
continue
}
selected = role
}
if selected == "" {
return "", errors.New("no authorized Pulse role")
}
return selected, nil
}
func Allows(role Role, permission Permission) bool {
level := map[Role]int{RoleViewer: 1, RoleOperator: 2, RoleEditor: 3, RoleAdministrator: 4}[role]
required := map[Permission]int{PermissionView: 1, PermissionOperate: 2, PermissionEdit: 3, PermissionAdmin: 4}[permission]
return level > 0 && required > 0 && level >= required
}
func Require(permission Permission, next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
principal, ok := PrincipalFromContext(request.Context())
if !ok {
response.Header().Set("Cache-Control", "private, no-store")
http.Error(response, "unauthorized", http.StatusUnauthorized)
return
}
if !Allows(principal.Role, permission) {
response.Header().Set("Cache-Control", "private, no-store")
http.Error(response, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(response, request)
})
}
type contextKey struct{}
func WithPrincipal(ctx context.Context, principal Principal) context.Context {
return context.WithValue(ctx, contextKey{}, principal)
}
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
principal, ok := ctx.Value(contextKey{}).(Principal)
return principal, ok && principal.Subject != ""
}
type BreakGlassPolicy struct {
Enabled bool
}
func (policy BreakGlassPolicy) Allows() bool { return policy.Enabled }