From 5d07a1ce841f674c8cec9a7bb0a22587ce3c5be3 Mon Sep 17 00:00:00 2001 From: Maarten de Kruijf <16100232+MaartendeKruijf@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:41:55 +0200 Subject: [PATCH 01/10] Added SOARCA roles for authentication --- pkg/auth/roles/roles.go | 36 +++++++++++++++++++++++++++++++ pkg/auth/roles/roles_test.go | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 pkg/auth/roles/roles.go create mode 100644 pkg/auth/roles/roles_test.go diff --git a/pkg/auth/roles/roles.go b/pkg/auth/roles/roles.go new file mode 100644 index 00000000..4449d39b --- /dev/null +++ b/pkg/auth/roles/roles.go @@ -0,0 +1,36 @@ +// Package roles defines SOARCA's permissions and the RoleMap type that +// identity providers use to grant them. Which roles or groups grant which +// permissions is up to each provider. +package roles + +// Permission is something a caller may do in SOARCA. Routes require +// permissions; identity providers grant them through a RoleMap. +type Permission string + +const ( + PlaybookRead Permission = "playbook:read" + PlaybookWrite Permission = "playbook:write" + PlaybookExecute Permission = "playbook:execute" + Admin Permission = "soarca:admin" +) + +// All returns every permission. +func All() []Permission { + return []Permission{PlaybookRead, PlaybookWrite, PlaybookExecute, Admin} +} + +// RoleMap maps a role or group name from an identity provider to the +// permissions it grants. +type RoleMap map[string][]Permission + +// Permissions returns the union of the permissions granted by roles. +// Unknown roles grant nothing. +func (roleMap RoleMap) Permissions(roles []string) map[Permission]bool { + granted := map[Permission]bool{} + for _, role := range roles { + for _, permission := range roleMap[role] { + granted[permission] = true + } + } + return granted +} diff --git a/pkg/auth/roles/roles_test.go b/pkg/auth/roles/roles_test.go new file mode 100644 index 00000000..3665e790 --- /dev/null +++ b/pkg/auth/roles/roles_test.go @@ -0,0 +1,41 @@ +package roles + +import ( + "testing" + + "github.com/go-playground/assert/v2" +) + +var testRoles = RoleMap{ + "reader": {PlaybookRead}, + "author": {PlaybookRead, PlaybookWrite}, + "runner": {PlaybookRead, PlaybookExecute}, + "admin": All(), +} + +func TestPermissionsAreTheUnionOfRoles(t *testing.T) { + granted := testRoles.Permissions([]string{"author", "runner"}) + + assert.Equal(t, granted, map[Permission]bool{PlaybookRead: true, PlaybookWrite: true, PlaybookExecute: true}) +} + +func TestUnknownRolesGrantNothing(t *testing.T) { + granted := testRoles.Permissions([]string{"Directory.ReadWrite.All", "Reader"}) + + assert.Equal(t, len(granted), 0) +} + +func TestEmptyRoleMapGrantsNothing(t *testing.T) { + granted := RoleMap{}.Permissions([]string{"admin"}) + + assert.Equal(t, len(granted), 0) +} + +func TestAllListsEveryPermission(t *testing.T) { + granted := testRoles.Permissions([]string{"admin"}) + + assert.Equal(t, len(granted), 4) + for _, permission := range All() { + assert.Equal(t, granted[permission], true) + } +} From 7434d81c9f0461d7066254d9652d7bfd554799a5 Mon Sep 17 00:00:00 2001 From: Maarten de Kruijf <16100232+MaartendeKruijf@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:42:57 +0200 Subject: [PATCH 02/10] Added the core authentication library --- go.mod | 4 +- pkg/auth/auth.go | 48 +++++++ pkg/auth/authenticator.go | 111 ++++++++++++++++ pkg/auth/authenticator_test.go | 111 ++++++++++++++++ pkg/auth/principal.go | 68 ++++++++++ pkg/auth/principal_test.go | 34 +++++ pkg/auth/token.go | 76 +++++++++++ pkg/auth/token_test.go | 73 +++++++++++ .../fake_identity_provider.go | 124 ++++++++++++++++++ test/unittest/mocks/mock_auth/mock_auth.go | 38 ++++++ 10 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 pkg/auth/auth.go create mode 100644 pkg/auth/authenticator.go create mode 100644 pkg/auth/authenticator_test.go create mode 100644 pkg/auth/principal.go create mode 100644 pkg/auth/principal_test.go create mode 100644 pkg/auth/token.go create mode 100644 pkg/auth/token_test.go create mode 100644 test/unittest/fakes/fake_identity_provider/fake_identity_provider.go create mode 100644 test/unittest/mocks/mock_auth/mock_auth.go diff --git a/go.mod b/go.mod index 95510738..e1c2e497 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,11 @@ go 1.25.0 require ( github.com/COSSAS/gauth v1.0.3 github.com/caarlos0/env/v11 v11.4.1 + github.com/coreos/go-oidc/v3 v3.11.0 github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/gin-contrib/cors v1.7.1 github.com/gin-gonic/gin v1.12.0 + github.com/go-jose/go-jose/v4 v4.0.5 github.com/go-playground/assert/v2 v2.2.0 github.com/go-playground/validator/v10 v10.30.1 github.com/gofrs/uuid v4.4.0+incompatible @@ -38,11 +40,9 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/coreos/go-oidc/v3 v3.11.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.6 // indirect diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go new file mode 100644 index 00000000..ad7db177 --- /dev/null +++ b/pkg/auth/auth.go @@ -0,0 +1,48 @@ +// Package auth authenticates callers of the SOARCA API. +// +// Identity providers (Entra ID, generic OIDC, API keys; see the providers +// sub-packages) declare which credentials they handle. The Authenticator +// routes each credential to the one provider that handles it and returns a +// Principal. Permissions are defined in the roles package. +package auth + +import ( + "context" + "errors" +) + +// ErrUnauthenticated wraps every authentication failure. The wrapped error +// explains why, for logs; it must not be sent to the client. +var ErrUnauthenticated = errors.New("unauthenticated") + +// IAuthenticator turns a raw credential into the calling Principal. +type IAuthenticator interface { + Authenticate(ctx context.Context, credential string) (*Principal, error) +} + +// IIdentityProvider verifies the credentials it declares in Handles. +type IIdentityProvider interface { + // Name labels the provider; the Authenticator puts it in + // Principal.Provider. + Name() string + // Handles declares which credentials this provider verifies. It is read + // once, when the Authenticator is built. + Handles() Match + // Authenticate verifies credential and maps it to a Principal. + Authenticate(ctx context.Context, credential Credential) (*Principal, error) +} + +// Match describes the credentials a provider handles: JWTs from any of +// Issuers, and opaque credentials that start with Prefix. +type Match struct { + Issuers []string + Prefix string +} + +// Credential is a raw credential as routed by the Authenticator. Issuer is +// the unverified "iss" claim of a JWT and empty for prefixed credentials; +// providers must still verify it. +type Credential struct { + Raw string + Issuer string +} diff --git a/pkg/auth/authenticator.go b/pkg/auth/authenticator.go new file mode 100644 index 00000000..f17b5a7e --- /dev/null +++ b/pkg/auth/authenticator.go @@ -0,0 +1,111 @@ +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// Authenticator routes each credential to the provider that handles it. +type Authenticator struct { + byIssuer map[string]IIdentityProvider + byPrefix map[string]IIdentityProvider +} + +// New builds an Authenticator. It fails when providers conflict: the same +// name or issuer twice, or prefixes where one starts with the other. +func New(providers []IIdentityProvider) (*Authenticator, error) { + authenticator := &Authenticator{ + byIssuer: map[string]IIdentityProvider{}, + byPrefix: map[string]IIdentityProvider{}, + } + names := map[string]bool{} + for _, provider := range providers { + name := provider.Name() + if name == "" || names[name] { + return nil, fmt.Errorf("auth: provider name %q is empty or used twice", name) + } + names[name] = true + + match := provider.Handles() + if len(match.Issuers) == 0 && match.Prefix == "" { + return nil, fmt.Errorf("auth: provider %q handles no credentials", name) + } + for _, issuer := range match.Issuers { + if other, taken := authenticator.byIssuer[issuer]; taken { + return nil, fmt.Errorf("auth: issuer %q is handled by both %q and %q", issuer, other.Name(), name) + } + authenticator.byIssuer[issuer] = provider + } + if match.Prefix != "" { + for prefix, other := range authenticator.byPrefix { + if strings.HasPrefix(match.Prefix, prefix) || strings.HasPrefix(prefix, match.Prefix) { + return nil, fmt.Errorf("auth: prefix %q of %q overlaps %q of %q", match.Prefix, name, prefix, other.Name()) + } + } + authenticator.byPrefix[match.Prefix] = provider + } + } + if len(names) == 0 { + return nil, errors.New("auth: no identity providers configured") + } + return authenticator, nil +} + +// Authenticate verifies credential with the provider that handles it. +// Every failure wraps ErrUnauthenticated. +func (authenticator *Authenticator) Authenticate(ctx context.Context, raw string) (*Principal, error) { + if raw == "" { + return nil, fmt.Errorf("%w: no credential", ErrUnauthenticated) + } + provider, credential, err := authenticator.route(raw) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, err) + } + principal, err := provider.Authenticate(ctx, credential) + if err != nil { + return nil, fmt.Errorf("%w: %s: %w", ErrUnauthenticated, provider.Name(), err) + } + principal.Provider = provider.Name() + return principal, nil +} + +func (authenticator *Authenticator) route(raw string) (IIdentityProvider, Credential, error) { + for prefix, provider := range authenticator.byPrefix { + if strings.HasPrefix(raw, prefix) { + return provider, Credential{Raw: raw}, nil + } + } + issuer, err := PeekIssuer(raw) + if err != nil { + return nil, Credential{}, err + } + provider, ok := authenticator.byIssuer[issuer] + if !ok { + return nil, Credential{}, fmt.Errorf("untrusted issuer %q", issuer) + } + return provider, Credential{Raw: raw, Issuer: issuer}, nil +} + +// PeekIssuer reads the "iss" claim of a JWT without verifying anything. It +// only serves routing; providers verify the issuer themselves. +func PeekIssuer(token string) (string, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", errors.New("not a jwt") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", errors.New("malformed jwt payload") + } + var claims struct { + Issuer string `json:"iss"` + } + if err := json.Unmarshal(payload, &claims); err != nil || claims.Issuer == "" { + return "", errors.New("jwt has no issuer") + } + return claims.Issuer, nil +} diff --git a/pkg/auth/authenticator_test.go b/pkg/auth/authenticator_test.go new file mode 100644 index 00000000..7dd0ee9a --- /dev/null +++ b/pkg/auth/authenticator_test.go @@ -0,0 +1,111 @@ +package auth_test + +import ( + "context" + "encoding/base64" + "errors" + "testing" + + "soarca/pkg/auth" + "soarca/test/unittest/mocks/mock_auth" + + "github.com/go-playground/assert/v2" + "github.com/stretchr/testify/mock" +) + +// unsignedJwt builds a token with only an issuer; routing never checks signatures. +func unsignedJwt(issuer string) string { + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"iss":"` + issuer + `"}`)) + return "eyJhbGciOiJub25lIn0." + payload + ".sig" +} + +func provider(name string, match auth.Match) *mock_auth.MockIdentityProvider { + identityProvider := &mock_auth.MockIdentityProvider{} + identityProvider.On("Name").Return(name) + identityProvider.On("Handles").Return(match) + return identityProvider +} + +func TestRoutesJwtToTheProviderForItsIssuer(t *testing.T) { + entra := provider("entra", auth.Match{Issuers: []string{"https://login.example/tenant/v2.0"}}) + authentik := provider("authentik", auth.Match{Issuers: []string{"https://authentik.example/application/o/soarca/"}}) + token := unsignedJwt("https://authentik.example/application/o/soarca/") + authentik.On("Authenticate", mock.Anything, auth.Credential{Raw: token, Issuer: "https://authentik.example/application/o/soarca/"}). + Return(&auth.Principal{Subject: "user-1"}, nil) + + authenticator, err := auth.New([]auth.IIdentityProvider{entra, authentik}) + assert.Equal(t, err, nil) + principal, err := authenticator.Authenticate(context.Background(), token) + + assert.Equal(t, err, nil) + assert.Equal(t, principal.Subject, "user-1") + assert.Equal(t, principal.Provider, "authentik") + entra.AssertNotCalled(t, "Authenticate", mock.Anything, mock.Anything) +} + +func TestRoutesPrefixedCredentialsToTheirProvider(t *testing.T) { + jwt := provider("entra", auth.Match{Issuers: []string{"https://login.example/tenant/v2.0"}}) + apiKeys := provider("apikey", auth.Match{Prefix: "soarca_"}) + apiKeys.On("Authenticate", mock.Anything, auth.Credential{Raw: "soarca_abc"}).Return(&auth.Principal{Subject: "webhook"}, nil) + + authenticator, err := auth.New([]auth.IIdentityProvider{jwt, apiKeys}) + assert.Equal(t, err, nil) + principal, err := authenticator.Authenticate(context.Background(), "soarca_abc") + + assert.Equal(t, err, nil) + assert.Equal(t, principal.Provider, "apikey") +} + +func TestProviderErrorsAreUnauthenticated(t *testing.T) { + failing := provider("authentik", auth.Match{Issuers: []string{"https://issuer.example/"}}) + token := unsignedJwt("https://issuer.example/") + failing.On("Authenticate", mock.Anything, mock.Anything).Return(nil, errors.New("bad signature")) + + authenticator, err := auth.New([]auth.IIdentityProvider{failing}) + assert.Equal(t, err, nil) + _, err = authenticator.Authenticate(context.Background(), token) + + assert.Equal(t, errors.Is(err, auth.ErrUnauthenticated), true) +} + +func TestRejectsCredentialsNoProviderHandles(t *testing.T) { + authenticator, err := auth.New([]auth.IIdentityProvider{provider("authentik", auth.Match{Issuers: []string{"https://issuer.example/"}})}) + assert.Equal(t, err, nil) + + for name, credential := range map[string]string{ + "empty": "", + "not a jwt": "abc", + "bad payload": "a.!!!.c", + "no issuer": "a." + base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"x"}`)) + ".c", + "unknown issuer": unsignedJwt("https://evil.example/"), + } { + _, err := authenticator.Authenticate(context.Background(), credential) + if !errors.Is(err, auth.ErrUnauthenticated) { + t.Errorf("%s: got %v, want ErrUnauthenticated", name, err) + } + } +} + +func TestNewRejectsConflictingProviders(t *testing.T) { + issuer := auth.Match{Issuers: []string{"https://a/"}} + + for name, providers := range map[string][]auth.IIdentityProvider{ + "none": nil, + "empty name": {provider("", issuer)}, + "duplicate name": {provider("a", issuer), provider("a", auth.Match{Prefix: "x_"})}, + "handles nothing": {provider("a", auth.Match{})}, + "duplicate issuer": {provider("a", issuer), provider("b", issuer)}, + "overlapping prefix": {provider("a", auth.Match{Prefix: "soarca_"}), provider("b", auth.Match{Prefix: "soarca_x"})}, + } { + if _, err := auth.New(providers); err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +func TestPeekIssuer(t *testing.T) { + issuer, err := auth.PeekIssuer(unsignedJwt("https://issuer.example/")) + + assert.Equal(t, err, nil) + assert.Equal(t, issuer, "https://issuer.example/") +} diff --git a/pkg/auth/principal.go b/pkg/auth/principal.go new file mode 100644 index 00000000..109d11fb --- /dev/null +++ b/pkg/auth/principal.go @@ -0,0 +1,68 @@ +package auth + +import ( + "time" + + "soarca/pkg/auth/roles" +) + +// Kinds of principal. +const ( + KindUser = "user" + KindApp = "app" +) + +// Principal is an authenticated caller, whichever way it authenticated. +type Principal struct { + // Provider names the verifier that accepted the credential. + Provider string `json:"provider"` + Issuer string `json:"issuer,omitempty"` + // Subject is the provider's stable identifier for the caller. + Subject string `json:"subject"` + TenantId string `json:"tenant_id,omitempty"` + // ClientId is the calling application, when the provider reports one. + ClientId string `json:"client_id,omitempty"` + // Kind is KindUser, KindApp, or empty when the provider can't tell. + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + // Roles holds the raw roles or groups from the credential. + Roles []string `json:"roles"` + Permissions map[roles.Permission]bool `json:"permissions"` + ExpiresAt time.Time `json:"expires_at"` + // Subjects lists what the caller is, for per-object access lists: + // Subjects[0] is the caller itself, the rest are its groups. Build + // entries with UserSubject, AppSubject and GroupSubject. + Subjects []string `json:"subjects"` + // GroupsIncomplete is set when the credential could not list every + // group, so group-based grants may be missing. + GroupsIncomplete bool `json:"groups_incomplete,omitempty"` +} + +// Has reports whether the principal holds permission. +func (principal *Principal) Has(permission roles.Permission) bool { + return principal != nil && principal.Permissions[permission] +} + +// Self returns the subject that identifies the caller itself. +func (principal *Principal) Self() string { + if principal == nil || len(principal.Subjects) == 0 { + return "" + } + return principal.Subjects[0] +} + +// UserSubject, AppSubject and GroupSubject build namespaced subjects of the +// form "::", so identifiers from different providers +// never collide. Providers choose their namespace. +func UserSubject(namespace string, id string) string { + return namespace + ":user:" + id +} + +func AppSubject(namespace string, id string) string { + return namespace + ":app:" + id +} + +func GroupSubject(namespace string, id string) string { + return namespace + ":group:" + id +} diff --git a/pkg/auth/principal_test.go b/pkg/auth/principal_test.go new file mode 100644 index 00000000..a5a2eaef --- /dev/null +++ b/pkg/auth/principal_test.go @@ -0,0 +1,34 @@ +package auth + +import ( + "testing" + + "soarca/pkg/auth/roles" + + "github.com/go-playground/assert/v2" +) + +func TestHas(t *testing.T) { + principal := &Principal{Permissions: map[roles.Permission]bool{roles.PlaybookRead: true}} + + assert.Equal(t, principal.Has(roles.PlaybookRead), true) + assert.Equal(t, principal.Has(roles.PlaybookExecute), false) +} + +func TestNilPrincipalHasNothing(t *testing.T) { + var principal *Principal + + assert.Equal(t, principal.Has(roles.Admin), false) + assert.Equal(t, principal.Self(), "") +} + +func TestSubjects(t *testing.T) { + principal := &Principal{Subjects: []string{ + UserSubject("entra:tenant", "oid-1"), + GroupSubject("entra:tenant", "group-1"), + }} + + assert.Equal(t, principal.Self(), "entra:tenant:user:oid-1") + assert.Equal(t, principal.Subjects[1], "entra:tenant:group:group-1") + assert.Equal(t, AppSubject("apikey", "webhook"), "apikey:app:webhook") +} diff --git a/pkg/auth/token.go b/pkg/auth/token.go new file mode 100644 index 00000000..36521e4d --- /dev/null +++ b/pkg/auth/token.go @@ -0,0 +1,76 @@ +package auth + +import ( + "context" + "fmt" + "slices" + "time" + + "github.com/coreos/go-oidc/v3/oidc" +) + +// ClockLeeway is how far past expiry a token is still accepted, to absorb +// clock differences between SOARCA and the identity provider. +const ClockLeeway = 30 * time.Second + +// TokenValidator checks a JWT's signature, issuer, expiry and audience. +// Provider packages use it and add their own claim checks on top. +type TokenValidator struct { + audiences []string + verifier *oidc.IDTokenVerifier +} + +// Token is a validated JWT. +type Token struct { + Issuer string + Subject string + Audience []string + Expiry time.Time + verified *oidc.IDToken +} + +// Claims decodes the token's claims into target. +func (token Token) Claims(target any) error { + return token.verified.Claims(target) +} + +// NewTokenValidator accepts tokens from issuer, signed by a key in keys with +// one of algorithms (RS256 when empty), carrying one of audiences. +func NewTokenValidator(issuer string, keys oidc.KeySet, audiences []string, algorithms []string) *TokenValidator { + return &TokenValidator{ + audiences: audiences, + verifier: oidc.NewVerifier(issuer, keys, &oidc.Config{ + // go-oidc checks a single client ID; audiences are checked below. + SkipClientIDCheck: true, + SupportedSigningAlgs: algorithms, + Now: func() time.Time { return time.Now().Add(-ClockLeeway) }, + }), + } +} + +// Validate verifies rawToken and returns it. +func (validator *TokenValidator) Validate(ctx context.Context, rawToken string) (Token, error) { + verified, err := validator.verifier.Verify(ctx, rawToken) + if err != nil { + return Token{}, err + } + if !containsAny(verified.Audience, validator.audiences) { + return Token{}, fmt.Errorf("audience %v is not accepted", verified.Audience) + } + return Token{ + Issuer: verified.Issuer, + Subject: verified.Subject, + Audience: verified.Audience, + Expiry: verified.Expiry, + verified: verified, + }, nil +} + +func containsAny(have []string, want []string) bool { + for _, candidate := range want { + if candidate != "" && slices.Contains(have, candidate) { + return true + } + } + return false +} diff --git a/pkg/auth/token_test.go b/pkg/auth/token_test.go new file mode 100644 index 00000000..67ca4944 --- /dev/null +++ b/pkg/auth/token_test.go @@ -0,0 +1,73 @@ +package auth_test + +import ( + "context" + "testing" + "time" + + "soarca/pkg/auth" + "soarca/test/unittest/fakes/fake_identity_provider" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-playground/assert/v2" +) + +const audience = "soarca-api" + +func newValidator(t *testing.T) (*auth.TokenValidator, *fake_identity_provider.IdentityProvider) { + provider := fake_identity_provider.New(t, false) + provider.ServeKeys("/keys") + keys := oidc.NewRemoteKeySet(context.Background(), provider.Url("/keys")) + return auth.NewTokenValidator(provider.Url("/"), keys, []string{audience}, nil), provider +} + +func TestValidateAcceptsAValidToken(t *testing.T) { + validator, provider := newValidator(t) + + token, err := validator.Validate(context.Background(), provider.Sign(t, map[string]any{ + "iss": provider.Url("/"), "aud": audience, "sub": "caller", "name": "Caller", + })) + + assert.Equal(t, err, nil) + assert.Equal(t, token.Subject, "caller") + var claims struct { + Name string `json:"name"` + } + assert.Equal(t, token.Claims(&claims), nil) + assert.Equal(t, claims.Name, "Caller") +} + +func TestValidateRejects(t *testing.T) { + validator, provider := newValidator(t) + valid := map[string]any{"iss": provider.Url("/"), "aud": audience, "sub": "caller"} + with := func(name string, value any) map[string]any { + claims := map[string]any{} + for key, existing := range valid { + claims[key] = existing + } + claims[name] = value + return claims + } + + for name, token := range map[string]string{ + "wrong audience": provider.Sign(t, with("aud", "graph")), + "wrong issuer": provider.Sign(t, with("iss", "https://evil.example/")), + "expired": provider.Sign(t, with("exp", time.Now().Add(-time.Hour).Unix())), + "unpublished key": provider.SignWithUnknownKey(t, valid), + } { + _, err := validator.Validate(context.Background(), token) + if err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +func TestValidateAllowsClockLeeway(t *testing.T) { + validator, provider := newValidator(t) + + _, err := validator.Validate(context.Background(), provider.Sign(t, map[string]any{ + "iss": provider.Url("/"), "aud": audience, "exp": time.Now().Add(-auth.ClockLeeway / 2).Unix(), + })) + + assert.Equal(t, err, nil) +} diff --git a/test/unittest/fakes/fake_identity_provider/fake_identity_provider.go b/test/unittest/fakes/fake_identity_provider/fake_identity_provider.go new file mode 100644 index 00000000..d41b45d0 --- /dev/null +++ b/test/unittest/fakes/fake_identity_provider/fake_identity_provider.go @@ -0,0 +1,124 @@ +// Package mock_identity_provider is an in-process identity provider for +// tests that need real signed JWTs: it serves a JWKS and OIDC discovery +// documents and signs tokens with its key. +package fake_identity_provider + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + jose "github.com/go-jose/go-jose/v4" +) + +const keyId = "test-key" + +type IdentityProvider struct { + Server *httptest.Server + key *rsa.PrivateKey + unknown *rsa.PrivateKey + mux *http.ServeMux +} + +// New starts a provider that is shut down when the test ends. With useTls +// it serves HTTPS with a self-signed certificate. +func New(t *testing.T, useTls bool) *IdentityProvider { + t.Helper() + provider := &IdentityProvider{ + key: generateKey(t), + unknown: generateKey(t), + mux: http.NewServeMux(), + } + if useTls { + provider.Server = httptest.NewTLSServer(provider.mux) + } else { + provider.Server = httptest.NewServer(provider.mux) + } + t.Cleanup(provider.Server.Close) + return provider +} + +// Url returns the absolute URL of path on this provider. +func (provider *IdentityProvider) Url(path string) string { + return provider.Server.URL + path +} + +// ServeKeys publishes the signing key as a JWKS at path. +func (provider *IdentityProvider) ServeKeys(path string) { + keySet := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{ + {Key: &provider.key.PublicKey, KeyID: keyId, Algorithm: string(jose.RS256), Use: "sig"}, + }} + provider.mux.HandleFunc(path, func(writer http.ResponseWriter, _ *http.Request) { + writeJson(writer, keySet) + }) +} + +// ServeDiscovery publishes an OIDC discovery document for the issuer at +// issuerPath (ending in "/"), with its JWKS next to it. +func (provider *IdentityProvider) ServeDiscovery(issuerPath string) { + provider.ServeKeys(issuerPath + "jwks/") + provider.mux.HandleFunc(issuerPath+".well-known/openid-configuration", func(writer http.ResponseWriter, _ *http.Request) { + writeJson(writer, map[string]any{ + "issuer": provider.Url(issuerPath), + "jwks_uri": provider.Url(issuerPath + "jwks/"), + "authorization_endpoint": provider.Url(issuerPath + "authorize/"), + "token_endpoint": provider.Url(issuerPath + "token/"), + "id_token_signing_alg_values_supported": []string{string(jose.RS256)}, + }) + }) +} + +// Sign returns a JWT with claims, signed with the published key. It adds +// "exp" one hour ahead unless claims sets it. +func (provider *IdentityProvider) Sign(t *testing.T, claims map[string]any) string { + return sign(t, provider.key, claims) +} + +// SignWithUnknownKey signs with a key that is never published. +func (provider *IdentityProvider) SignWithUnknownKey(t *testing.T, claims map[string]any) string { + return sign(t, provider.unknown, claims) +} + +func sign(t *testing.T, key *rsa.PrivateKey, claims map[string]any) string { + t.Helper() + payload := map[string]any{"iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix()} + for name, value := range claims { + payload[name] = value + } + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", keyId)) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + signed, err := signer.Sign(encoded) + if err != nil { + t.Fatal(err) + } + compact, err := signed.CompactSerialize() + if err != nil { + t.Fatal(err) + } + return compact +} + +func generateKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return key +} + +func writeJson(writer http.ResponseWriter, value any) { + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(value) +} diff --git a/test/unittest/mocks/mock_auth/mock_auth.go b/test/unittest/mocks/mock_auth/mock_auth.go new file mode 100644 index 00000000..67d65ad7 --- /dev/null +++ b/test/unittest/mocks/mock_auth/mock_auth.go @@ -0,0 +1,38 @@ +package mock_auth + +import ( + "context" + "soarca/pkg/auth" + + "github.com/stretchr/testify/mock" +) + +type MockAuthenticator struct { + mock.Mock +} + +func (authenticator *MockAuthenticator) Authenticate(ctx context.Context, credential string) (*auth.Principal, error) { + args := authenticator.Called(ctx, credential) + principal, _ := args.Get(0).(*auth.Principal) + return principal, args.Error(1) +} + +type MockIdentityProvider struct { + mock.Mock +} + +func (provider *MockIdentityProvider) Name() string { + args := provider.Called() + return args.String(0) +} + +func (provider *MockIdentityProvider) Handles() auth.Match { + args := provider.Called() + return args.Get(0).(auth.Match) +} + +func (provider *MockIdentityProvider) Authenticate(ctx context.Context, credential auth.Credential) (*auth.Principal, error) { + args := provider.Called(ctx, credential) + principal, _ := args.Get(0).(*auth.Principal) + return principal, args.Error(1) +} From 561deaf7ad57c2feb5e9f6ae659bcde9beaba842 Mon Sep 17 00:00:00 2001 From: Maarten de Kruijf <16100232+MaartendeKruijf@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:43:20 +0200 Subject: [PATCH 03/10] Added OIDC provider for Authentik and other compatible providers --- pkg/auth/providers/oidc/claims.go | 62 +++++++++++++ pkg/auth/providers/oidc/claims_test.go | 72 ++++++++++++++++ pkg/auth/providers/oidc/config.go | 59 +++++++++++++ pkg/auth/providers/oidc/provider.go | 105 +++++++++++++++++++++++ pkg/auth/providers/oidc/provider_test.go | 94 ++++++++++++++++++++ pkg/auth/providers/oidc/roles.go | 21 +++++ pkg/auth/providers/oidc/roles_test.go | 28 ++++++ 7 files changed, 441 insertions(+) create mode 100644 pkg/auth/providers/oidc/claims.go create mode 100644 pkg/auth/providers/oidc/claims_test.go create mode 100644 pkg/auth/providers/oidc/config.go create mode 100644 pkg/auth/providers/oidc/provider.go create mode 100644 pkg/auth/providers/oidc/provider_test.go create mode 100644 pkg/auth/providers/oidc/roles.go create mode 100644 pkg/auth/providers/oidc/roles_test.go diff --git a/pkg/auth/providers/oidc/claims.go b/pkg/auth/providers/oidc/claims.go new file mode 100644 index 00000000..c2d23bb7 --- /dev/null +++ b/pkg/auth/providers/oidc/claims.go @@ -0,0 +1,62 @@ +package oidc + +import ( + "strings" + + "soarca/pkg/auth" +) + +// principal maps the claims of a token that passed signature, issuer, +// expiry and audience checks to a Principal. +func (config Config) principal(token auth.Token, claims map[string]any) *auth.Principal { + groups := stringList(claims[config.GroupsClaim]) + + // Access lists key on group IDs, so renaming a group doesn't break them. + groupIds := groups + if raw, ok := claims[config.GroupIdsClaim]; ok { + groupIds = stringList(raw) + } + subjects := []string{auth.UserSubject(config.Name, token.Subject)} + for _, group := range groupIds { + subjects = append(subjects, auth.GroupSubject(config.Name, group)) + } + + name := stringClaim(claims["name"]) + if name == "" { + name = stringClaim(claims["preferred_username"]) + } + return &auth.Principal{ + Issuer: token.Issuer, + Subject: token.Subject, + ClientId: stringClaim(claims["azp"]), + Name: name, + Email: stringClaim(claims["email"]), + Roles: groups, + Permissions: config.Roles.Permissions(groups), + ExpiresAt: token.Expiry, + Subjects: subjects, + } +} + +// stringList reads a claim that is either a JSON array of strings or a +// space-separated string. +func stringList(value any) []string { + switch typed := value.(type) { + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if text, ok := item.(string); ok { + out = append(out, text) + } + } + return out + case string: + return strings.Fields(typed) + } + return nil +} + +func stringClaim(value any) string { + text, _ := value.(string) + return text +} diff --git a/pkg/auth/providers/oidc/claims_test.go b/pkg/auth/providers/oidc/claims_test.go new file mode 100644 index 00000000..a3d91390 --- /dev/null +++ b/pkg/auth/providers/oidc/claims_test.go @@ -0,0 +1,72 @@ +package oidc + +import ( + "testing" + "time" + + "soarca/pkg/auth" + "soarca/pkg/auth/roles" + + "github.com/go-playground/assert/v2" +) + +var token = auth.Token{Issuer: "https://authentik.example/application/o/soarca/", Subject: "user-1", Expiry: time.Unix(2000000000, 0)} + +func testConfig() Config { + return Config{Name: "authentik", IssuerUrl: token.Issuer, Audiences: []string{"soarca"}}.withDefaults() +} + +func TestGroupsGrantPermissions(t *testing.T) { + principal := testConfig().principal(token, map[string]any{ + "groups": []any{"soarca_execute"}, "name": "Bob", "email": "bob@example.com", "azp": "soarca", + }) + + assert.Equal(t, principal.Name, "Bob") + assert.Equal(t, principal.Email, "bob@example.com") + assert.Equal(t, principal.ClientId, "soarca") + assert.Equal(t, principal.Permissions, map[roles.Permission]bool{roles.PlaybookRead: true, roles.PlaybookExecute: true}) + assert.Equal(t, principal.ExpiresAt, token.Expiry) +} + +func TestGauthAdminGroupKeepsFullAccess(t *testing.T) { + principal := testConfig().principal(token, map[string]any{"groups": []any{DefaultAdminGroup}}) + + for _, permission := range roles.All() { + assert.Equal(t, principal.Has(permission), true) + } +} + +func TestSubjectsUseGroupIds(t *testing.T) { + principal := testConfig().principal(token, map[string]any{ + "groups": []any{"soarca_read"}, "group_ids": []any{"uuid-1"}, + }) + + assert.Equal(t, principal.Subjects, []string{"authentik:user:user-1", "authentik:group:uuid-1"}) +} + +func TestSubjectsFallBackToGroupNames(t *testing.T) { + principal := testConfig().principal(token, map[string]any{"groups": []any{"soarca_read"}}) + + assert.Equal(t, principal.Subjects, []string{"authentik:user:user-1", "authentik:group:soarca_read"}) +} + +func TestNameFallsBackToUsername(t *testing.T) { + principal := testConfig().principal(token, map[string]any{"preferred_username": "bob"}) + + assert.Equal(t, principal.Name, "bob") +} + +func TestStringList(t *testing.T) { + assert.Equal(t, stringList([]any{"a", 1, "b"}), []string{"a", "b"}) + assert.Equal(t, stringList("a b"), []string{"a", "b"}) + assert.Equal(t, len(stringList(nil)), 0) +} + +func TestDefaults(t *testing.T) { + config := Config{IssuerUrl: "https://idp/", Audiences: []string{"x"}}.withDefaults() + + assert.Equal(t, config.Name, "oidc") + assert.Equal(t, config.GroupsClaim, "groups") + assert.Equal(t, config.GroupIdsClaim, "group_ids") + assert.NotEqual(t, config.Roles, nil) +} diff --git a/pkg/auth/providers/oidc/config.go b/pkg/auth/providers/oidc/config.go new file mode 100644 index 00000000..8ad5df72 --- /dev/null +++ b/pkg/auth/providers/oidc/config.go @@ -0,0 +1,59 @@ +// Package oidc verifies access tokens from a standard OpenID Connect +// provider, such as authentik, found through discovery. +package oidc + +import ( + "errors" + + "soarca/pkg/auth/roles" +) + +// Config describes one OIDC provider. +type Config struct { + // Name labels the provider in principals and access-list subjects. + // Empty means "oidc". + Name string + // IssuerUrl must equal the tokens' "iss" claim exactly. For authentik: + // https:///application/o// including the trailing slash. + IssuerUrl string + // Audiences accepted in "aud", normally the provider's client ID. + Audiences []string + // GroupsClaim holds the group names used for permissions. + // Empty means "groups". + GroupsClaim string + // GroupIdsClaim holds stable group IDs for access lists. Empty means + // "group_ids". Tokens without it fall back to the group names. + GroupIdsClaim string + // Roles maps group names to permissions. Nil means + // DefaultRoles(DefaultAdminGroup). + Roles roles.RoleMap + // SkipTlsVerify disables certificate checks towards the provider. Only + // for test setups with self-signed certificates. + SkipTlsVerify bool +} + +func (config Config) withDefaults() Config { + if config.Name == "" { + config.Name = "oidc" + } + if config.GroupsClaim == "" { + config.GroupsClaim = "groups" + } + if config.GroupIdsClaim == "" { + config.GroupIdsClaim = "group_ids" + } + if config.Roles == nil { + config.Roles = DefaultRoles(DefaultAdminGroup) + } + return config +} + +func (config Config) validate() error { + if config.IssuerUrl == "" { + return errors.New("oidc: the issuer URL is required") + } + if len(config.Audiences) == 0 { + return errors.New("oidc: at least one audience (client ID) is required") + } + return nil +} diff --git a/pkg/auth/providers/oidc/provider.go b/pkg/auth/providers/oidc/provider.go new file mode 100644 index 00000000..29626935 --- /dev/null +++ b/pkg/auth/providers/oidc/provider.go @@ -0,0 +1,105 @@ +package oidc + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "reflect" + "sync" + "time" + + "soarca/internal/logger" + "soarca/pkg/auth" + + gooidc "github.com/coreos/go-oidc/v3/oidc" +) + +const discoveryTimeout = 10 * time.Second + +var log *logger.Log + +func init() { + log = logger.Logger(reflect.TypeOf(Provider{}).PkgPath(), logger.Info, "", logger.Json) +} + +// Provider verifies tokens from one OIDC provider. Discovery runs on the +// first token and is retried after a failure, so SOARCA can start before +// the provider is reachable. +type Provider struct { + config Config + client *http.Client + + mutex sync.Mutex + validator *auth.TokenValidator +} + +// New builds a Provider without contacting the identity provider. +func New(config Config) (*Provider, error) { + if err := config.validate(); err != nil { + return nil, err + } + config = config.withDefaults() + client := http.DefaultClient + if config.SkipTlsVerify { + log.Warning(fmt.Sprintf("TLS verification towards %s is disabled; use this for testing only", config.IssuerUrl)) + client = &http.Client{Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // explicitly configured with SkipTlsVerify + }} + } + return &Provider{config: config, client: client}, nil +} + +func (provider *Provider) Name() string { + return provider.config.Name +} + +// Handles declares the configured issuer. +func (provider *Provider) Handles() auth.Match { + return auth.Match{Issuers: []string{provider.config.IssuerUrl}} +} + +// Authenticate verifies the token and maps it to a Principal. +func (provider *Provider) Authenticate(ctx context.Context, credential auth.Credential) (*auth.Principal, error) { + validator, err := provider.tokenValidator(ctx) + if err != nil { + return nil, err + } + validated, err := validator.Validate(ctx, credential.Raw) + if err != nil { + return nil, err + } + var claims map[string]any + if err := validated.Claims(&claims); err != nil { + return nil, err + } + return provider.config.principal(validated, claims), nil +} + +func (provider *Provider) tokenValidator(ctx context.Context) (*auth.TokenValidator, error) { + provider.mutex.Lock() + defer provider.mutex.Unlock() + if provider.validator != nil { + return provider.validator, nil + } + + discoveryCtx, cancel := context.WithTimeout(gooidc.ClientContext(ctx, provider.client), discoveryTimeout) + defer cancel() + discovered, err := gooidc.NewProvider(discoveryCtx, provider.config.IssuerUrl) + if err != nil { + return nil, fmt.Errorf("discovery: %w", err) + } + var metadata struct { + JwksUri string `json:"jwks_uri"` + Algorithms []string `json:"id_token_signing_alg_values_supported"` + } + if err := discovered.Claims(&metadata); err != nil || metadata.JwksUri == "" { + return nil, fmt.Errorf("discovery: %s has no jwks_uri", provider.config.IssuerUrl) + } + + // The key set outlives this request, so it gets its own context. + keys := gooidc.NewRemoteKeySet(gooidc.ClientContext(context.Background(), provider.client), metadata.JwksUri) + provider.validator = auth.NewTokenValidator(provider.config.IssuerUrl, keys, provider.config.Audiences, metadata.Algorithms) + return provider.validator, nil +} diff --git a/pkg/auth/providers/oidc/provider_test.go b/pkg/auth/providers/oidc/provider_test.go new file mode 100644 index 00000000..814c9143 --- /dev/null +++ b/pkg/auth/providers/oidc/provider_test.go @@ -0,0 +1,94 @@ +package oidc + +import ( + "context" + "testing" + + "soarca/pkg/auth" + "soarca/test/unittest/fakes/fake_identity_provider" + + "github.com/go-playground/assert/v2" +) + +const issuerPath = "/application/o/soarca/" + +func fakeAuthentik(t *testing.T, useTls bool) *fake_identity_provider.IdentityProvider { + server := fake_identity_provider.New(t, useTls) + server.ServeDiscovery(issuerPath) + return server +} + +func credential(t *testing.T, server *fake_identity_provider.IdentityProvider, claims map[string]any) auth.Credential { + return auth.Credential{Issuer: server.Url(issuerPath), Raw: server.Sign(t, claims)} +} + +func TestNameAndHandles(t *testing.T) { + server := fakeAuthentik(t, false) + provider, err := New(Config{Name: "authentik", IssuerUrl: server.Url(issuerPath), Audiences: []string{"soarca"}}) + assert.Equal(t, err, nil) + + assert.Equal(t, provider.Name(), "authentik") + assert.Equal(t, provider.Handles(), auth.Match{Issuers: []string{server.Url(issuerPath)}}) +} + +func TestAuthenticateThroughDiscovery(t *testing.T) { + server := fakeAuthentik(t, false) + provider, err := New(Config{Name: "authentik", IssuerUrl: server.Url(issuerPath), Audiences: []string{"soarca"}}) + assert.Equal(t, err, nil) + + principal, err := provider.Authenticate(context.Background(), credential(t, server, map[string]any{ + "iss": server.Url(issuerPath), "aud": "soarca", "sub": "user-1", "groups": []string{"soarca_read"}, + })) + + assert.Equal(t, err, nil) + assert.Equal(t, principal.Self(), "authentik:user:user-1") +} + +func TestAuthenticateRejectsOtherAudience(t *testing.T) { + server := fakeAuthentik(t, false) + provider, err := New(Config{IssuerUrl: server.Url(issuerPath), Audiences: []string{"soarca"}}) + assert.Equal(t, err, nil) + + _, err = provider.Authenticate(context.Background(), credential(t, server, map[string]any{ + "iss": server.Url(issuerPath), "aud": "another-app", "sub": "user-1", + })) + + assert.NotEqual(t, err, nil) +} + +func TestSkipTlsVerify(t *testing.T) { + server := fakeAuthentik(t, true) // self-signed certificate + token := credential(t, server, map[string]any{"iss": server.Url(issuerPath), "aud": "soarca", "sub": "user-1"}) + + strict, err := New(Config{IssuerUrl: server.Url(issuerPath), Audiences: []string{"soarca"}}) + assert.Equal(t, err, nil) + _, err = strict.Authenticate(context.Background(), token) + assert.NotEqual(t, err, nil) + + lenient, err := New(Config{IssuerUrl: server.Url(issuerPath), Audiences: []string{"soarca"}, SkipTlsVerify: true}) + assert.Equal(t, err, nil) + _, err = lenient.Authenticate(context.Background(), token) + assert.Equal(t, err, nil) +} + +func TestDiscoveryIsRetriedAfterFailure(t *testing.T) { + server := fake_identity_provider.New(t, false) + provider, err := New(Config{IssuerUrl: server.Url(issuerPath), Audiences: []string{"soarca"}}) + assert.Equal(t, err, nil) + token := credential(t, server, map[string]any{"iss": server.Url(issuerPath), "aud": "soarca", "sub": "user-1"}) + + _, err = provider.Authenticate(context.Background(), token) + assert.NotEqual(t, err, nil) + + server.ServeDiscovery(issuerPath) + _, err = provider.Authenticate(context.Background(), token) + assert.Equal(t, err, nil) +} + +func TestNewRejectsIncompleteConfig(t *testing.T) { + _, err := New(Config{Audiences: []string{"soarca"}}) + assert.NotEqual(t, err, nil) + + _, err = New(Config{IssuerUrl: "https://idp/"}) + assert.NotEqual(t, err, nil) +} diff --git a/pkg/auth/providers/oidc/roles.go b/pkg/auth/providers/oidc/roles.go new file mode 100644 index 00000000..52d33225 --- /dev/null +++ b/pkg/auth/providers/oidc/roles.go @@ -0,0 +1,21 @@ +package oidc + +import "soarca/pkg/auth/roles" + +// DefaultAdminGroup is the group that gauth-based SOARCA required for all +// access. It keeps full access. +const DefaultAdminGroup = "soarca_admin" + +// DefaultRoles maps group names to SOARCA permissions. adminGroup gets every +// permission; empty means DefaultAdminGroup. +func DefaultRoles(adminGroup string) roles.RoleMap { + if adminGroup == "" { + adminGroup = DefaultAdminGroup + } + return roles.RoleMap{ + "soarca_read": {roles.PlaybookRead}, + "soarca_write": {roles.PlaybookRead, roles.PlaybookWrite}, + "soarca_execute": {roles.PlaybookRead, roles.PlaybookExecute}, + adminGroup: roles.All(), + } +} diff --git a/pkg/auth/providers/oidc/roles_test.go b/pkg/auth/providers/oidc/roles_test.go new file mode 100644 index 00000000..96776dbb --- /dev/null +++ b/pkg/auth/providers/oidc/roles_test.go @@ -0,0 +1,28 @@ +package oidc + +import ( + "testing" + + "soarca/pkg/auth/roles" + + "github.com/go-playground/assert/v2" +) + +func TestDefaultRolesKeepGauthAdminGroup(t *testing.T) { + assert.Equal(t, len(DefaultRoles("").Permissions([]string{DefaultAdminGroup})), len(roles.All())) +} + +func TestDefaultRolesWithCustomAdminGroup(t *testing.T) { + defaults := DefaultRoles("soc_leads") + + assert.Equal(t, len(defaults.Permissions([]string{"soc_leads"})), len(roles.All())) + assert.Equal(t, len(defaults.Permissions([]string{DefaultAdminGroup})), 0) +} + +func TestDefaultRolesForPlaybookGroups(t *testing.T) { + defaults := DefaultRoles("") + + assert.Equal(t, defaults.Permissions([]string{"soarca_read"}), map[roles.Permission]bool{roles.PlaybookRead: true}) + assert.Equal(t, defaults.Permissions([]string{"soarca_write"}), map[roles.Permission]bool{roles.PlaybookRead: true, roles.PlaybookWrite: true}) + assert.Equal(t, defaults.Permissions([]string{"soarca_execute"}), map[roles.Permission]bool{roles.PlaybookRead: true, roles.PlaybookExecute: true}) +} From f0425a9d76ebde47efe14eca952fc4130a492158 Mon Sep 17 00:00:00 2001 From: Maarten de Kruijf <16100232+MaartendeKruijf@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:43:33 +0200 Subject: [PATCH 04/10] Added basic API key provider --- pkg/auth/providers/apikey/provider.go | 143 +++++++++++++++++++++ pkg/auth/providers/apikey/provider_test.go | 85 ++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 pkg/auth/providers/apikey/provider.go create mode 100644 pkg/auth/providers/apikey/provider_test.go diff --git a/pkg/auth/providers/apikey/provider.go b/pkg/auth/providers/apikey/provider.go new file mode 100644 index 00000000..0aaf8c1f --- /dev/null +++ b/pkg/auth/providers/apikey/provider.go @@ -0,0 +1,143 @@ +// Package apikey verifies static API keys, for callers that cannot request +// an OAuth token, such as a webhook source that sends a fixed header. +// Only the SHA-256 of each key is stored. +package apikey + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "soarca/pkg/auth" + "soarca/pkg/auth/roles" +) + +// Prefix marks SOARCA API keys, so the authenticator can route them and +// secret scanners can recognise leaked ones. +const Prefix = "soarca_" + +// Name labels this provider in principals and logs. +const Name = "apikey" + +// Key is a stored API key. +type Key struct { + Name string `json:"name"` + Sha256 string `json:"sha256"` + Permissions []roles.Permission `json:"permissions"` + Expires time.Time `json:"expires"` +} + +func (key Key) validate() error { + if key.Name == "" { + return errors.New("api key needs a name") + } + if len(key.Sha256) != sha256.Size*2 { + return fmt.Errorf("api key %q needs a 64-character sha256", key.Name) + } + if key.Expires.IsZero() { + return fmt.Errorf("api key %q has no expiry", key.Name) + } + return nil +} + +// IStore looks up a key by the hex SHA-256 of its plaintext. +type IStore interface { + Lookup(sha256Hex string) (Key, bool) +} + +// MemoryStore is a read-only store, usually loaded with LoadFile. +type MemoryStore map[string]Key + +func (store MemoryStore) Lookup(sha256Hex string) (Key, bool) { + key, ok := store[sha256Hex] + return key, ok +} + +// LoadFile reads a JSON array of Key. +func LoadFile(path string) (MemoryStore, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("apikey: %w", err) + } + var keys []Key + if err := json.Unmarshal(content, &keys); err != nil { + return nil, fmt.Errorf("apikey: %w", err) + } + store := MemoryStore{} + for _, key := range keys { + if err := key.validate(); err != nil { + return nil, fmt.Errorf("apikey: %w", err) + } + store[key.Sha256] = key + } + return store, nil +} + +// Generate returns a new plaintext key, to hand to the caller once, and the +// hash to store. +func Generate() (plaintext string, sha256Hex string, err error) { + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + return "", "", err + } + plaintext = Prefix + base64.RawURLEncoding.EncodeToString(random) + return plaintext, Hash(plaintext), nil +} + +// Hash returns the hex SHA-256 of a plaintext key. +func Hash(plaintext string) string { + sum := sha256.Sum256([]byte(plaintext)) + return hex.EncodeToString(sum[:]) +} + +// Provider checks API keys against a store. +type Provider struct { + store IStore +} + +func New(store IStore) *Provider { + return &Provider{store: store} +} + +func (provider *Provider) Name() string { + return Name +} + +// Handles declares the API key prefix. +func (provider *Provider) Handles() auth.Match { + return auth.Match{Prefix: Prefix} +} + +// Authenticate looks up the key and maps it to a Principal. +func (provider *Provider) Authenticate(_ context.Context, credential auth.Credential) (*auth.Principal, error) { + // Looking up by hash leaves no timing signal about the secret itself. + key, ok := provider.store.Lookup(Hash(credential.Raw)) + if !ok { + return nil, errors.New("unknown api key") + } + if time.Now().After(key.Expires) { + return nil, fmt.Errorf("api key %q expired", key.Name) + } + permissions := map[roles.Permission]bool{} + names := make([]string, 0, len(key.Permissions)) + for _, permission := range key.Permissions { + permissions[permission] = true + names = append(names, string(permission)) + } + return &auth.Principal{ + Subject: key.Name, + Kind: auth.KindApp, + Name: key.Name, + Roles: names, + Permissions: permissions, + ExpiresAt: key.Expires, + Subjects: []string{auth.AppSubject(Name, key.Name)}, + }, nil +} diff --git a/pkg/auth/providers/apikey/provider_test.go b/pkg/auth/providers/apikey/provider_test.go new file mode 100644 index 00000000..7a82a1fb --- /dev/null +++ b/pkg/auth/providers/apikey/provider_test.go @@ -0,0 +1,85 @@ +package apikey + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "soarca/pkg/auth" + "soarca/pkg/auth/roles" + + "github.com/go-playground/assert/v2" +) + +func writeKeys(t *testing.T, keys []Key) string { + content, err := json.Marshal(keys) + assert.Equal(t, err, nil) + path := filepath.Join(t.TempDir(), "keys.json") + assert.Equal(t, os.WriteFile(path, content, 0o600), nil) + return path +} + +func TestGenerate(t *testing.T) { + plaintext, hash, err := Generate() + + assert.Equal(t, err, nil) + assert.Equal(t, strings.HasPrefix(plaintext, Prefix), true) + assert.Equal(t, hash, Hash(plaintext)) +} + +func TestVerifyValidKey(t *testing.T) { + plaintext, hash, _ := Generate() + provider := New(MemoryStore{hash: {Name: "webhook", Sha256: hash, Permissions: []roles.Permission{roles.PlaybookExecute}, Expires: time.Now().Add(time.Hour)}}) + + principal, err := provider.Authenticate(context.Background(), auth.Credential{Raw: plaintext}) + + assert.Equal(t, err, nil) + assert.Equal(t, principal.Self(), "apikey:app:webhook") + assert.Equal(t, principal.Permissions, map[roles.Permission]bool{roles.PlaybookExecute: true}) + assert.Equal(t, provider.Name(), "apikey") + assert.Equal(t, provider.Handles(), auth.Match{Prefix: Prefix}) +} + +func TestVerifyRejectsExpiredAndUnknownKeys(t *testing.T) { + expired, hash, _ := Generate() + provider := New(MemoryStore{hash: {Name: "old", Sha256: hash, Expires: time.Now().Add(-time.Hour)}}) + + _, err := provider.Authenticate(context.Background(), auth.Credential{Raw: expired}) + assert.NotEqual(t, err, nil) + + _, err = provider.Authenticate(context.Background(), auth.Credential{Raw: Prefix + "unknown"}) + assert.NotEqual(t, err, nil) +} + +func TestLoadFile(t *testing.T) { + _, hash, _ := Generate() + expires := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + + store, err := LoadFile(writeKeys(t, []Key{{Name: "webhook", Sha256: hash, Permissions: []roles.Permission{roles.PlaybookRead}, Expires: expires}})) + + assert.Equal(t, err, nil) + key, ok := store.Lookup(hash) + assert.Equal(t, ok, true) + assert.Equal(t, key.Name, "webhook") + assert.Equal(t, key.Expires.Equal(expires), true) +} + +func TestLoadFileRejectsInvalidKeys(t *testing.T) { + _, hash, _ := Generate() + for name, key := range map[string]Key{ + "no name": {Sha256: hash, Expires: time.Now()}, + "bad hash": {Name: "x", Sha256: "abc", Expires: time.Now()}, + "no expiry": {Name: "x", Sha256: hash}, + } { + if _, err := LoadFile(writeKeys(t, []Key{key})); err == nil { + t.Errorf("%s: accepted", name) + } + } + + _, err := LoadFile(filepath.Join(t.TempDir(), "missing.json")) + assert.NotEqual(t, err, nil) +} From 218e05f948a49a2c54bec3b27be464c4c64b8e2b Mon Sep 17 00:00:00 2001 From: Maarten de Kruijf <16100232+MaartendeKruijf@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:43:49 +0200 Subject: [PATCH 05/10] Added entra provider to authenticate with Azure Entra ID --- pkg/auth/providers/entra/claims.go | 101 +++++++++++++++ pkg/auth/providers/entra/claims_test.go | 144 ++++++++++++++++++++++ pkg/auth/providers/entra/config.go | 63 ++++++++++ pkg/auth/providers/entra/provider.go | 84 +++++++++++++ pkg/auth/providers/entra/provider_test.go | 92 ++++++++++++++ pkg/auth/providers/entra/roles.go | 14 +++ pkg/auth/providers/entra/roles_test.go | 23 ++++ 7 files changed, 521 insertions(+) create mode 100644 pkg/auth/providers/entra/claims.go create mode 100644 pkg/auth/providers/entra/claims_test.go create mode 100644 pkg/auth/providers/entra/config.go create mode 100644 pkg/auth/providers/entra/provider.go create mode 100644 pkg/auth/providers/entra/provider_test.go create mode 100644 pkg/auth/providers/entra/roles.go create mode 100644 pkg/auth/providers/entra/roles_test.go diff --git a/pkg/auth/providers/entra/claims.go b/pkg/auth/providers/entra/claims.go new file mode 100644 index 00000000..da5c2c75 --- /dev/null +++ b/pkg/auth/providers/entra/claims.go @@ -0,0 +1,101 @@ +package entra + +import ( + "fmt" + "slices" + "strings" + + "soarca/pkg/auth" +) + +// claims are the Entra ID access token claims SOARCA uses. +type claims struct { + TenantId string `json:"tid"` + ObjectId string `json:"oid"` + AuthorizedParty string `json:"azp"` // calling app, v2 tokens + AppId string `json:"appid"` // calling app, v1 tokens + Scope string `json:"scp"` // delegated scopes, user tokens only + IdentityType string `json:"idtyp"` // "app" or "user", when the optional claim is configured + Roles []string `json:"roles"` + Groups []string `json:"groups"` + ClaimNames map[string]string `json:"_claim_names"` // set when groups overflow the token + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Upn string `json:"upn"` +} + +func (tokenClaims claims) clientId() string { + if tokenClaims.AuthorizedParty != "" { + return tokenClaims.AuthorizedParty + } + return tokenClaims.AppId +} + +func (tokenClaims claims) kind() string { + switch tokenClaims.IdentityType { + case auth.KindApp, auth.KindUser: + return tokenClaims.IdentityType + } + // App-only tokens never carry delegated scopes. + if tokenClaims.Scope == "" { + return auth.KindApp + } + return auth.KindUser +} + +// principal checks the Entra-specific claims of a token that passed +// signature, issuer, expiry and audience checks, and maps it to a Principal. +func (config Config) principal(tenantId string, token auth.Token, tokenClaims claims) (*auth.Principal, error) { + if !strings.EqualFold(tokenClaims.TenantId, tenantId) { + return nil, fmt.Errorf("tenant %q does not match issuer tenant %q", tokenClaims.TenantId, tenantId) + } + clientId := tokenClaims.clientId() + if len(config.AllowedClientIds) > 0 && !slices.Contains(config.AllowedClientIds, clientId) { + return nil, fmt.Errorf("client %q is not allowed", clientId) + } + kind := tokenClaims.kind() + if kind == auth.KindUser && config.RequiredUserScope != "" && + !slices.Contains(strings.Fields(tokenClaims.Scope), config.RequiredUserScope) { + return nil, fmt.Errorf("user token lacks scope %q", config.RequiredUserScope) + } + + subject := tokenClaims.ObjectId + if subject == "" { + subject = token.Subject + } + namespace := "entra:" + strings.ToLower(tokenClaims.TenantId) + self := auth.UserSubject(namespace, subject) + if kind == auth.KindApp { + // Administrators know apps by client ID, so access lists use it. + self = auth.AppSubject(namespace, clientId) + } + subjects := []string{self} + for _, group := range tokenClaims.Groups { + subjects = append(subjects, auth.GroupSubject(namespace, strings.ToLower(group))) + } + _, groupsOverflowed := tokenClaims.ClaimNames["groups"] + + return &auth.Principal{ + Issuer: token.Issuer, + Subject: subject, + TenantId: tokenClaims.TenantId, + ClientId: clientId, + Kind: kind, + Name: firstNonEmpty(tokenClaims.Name, tokenClaims.PreferredUsername, tokenClaims.Upn), + Email: firstNonEmpty(tokenClaims.PreferredUsername, tokenClaims.Upn), + Roles: tokenClaims.Roles, + Permissions: config.Roles.Permissions(tokenClaims.Roles), + ExpiresAt: token.Expiry, + Subjects: subjects, + GroupsIncomplete: groupsOverflowed, + }, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/pkg/auth/providers/entra/claims_test.go b/pkg/auth/providers/entra/claims_test.go new file mode 100644 index 00000000..63f4e39d --- /dev/null +++ b/pkg/auth/providers/entra/claims_test.go @@ -0,0 +1,144 @@ +package entra + +import ( + "testing" + "time" + + "soarca/pkg/auth" + "soarca/pkg/auth/roles" + + "github.com/go-playground/assert/v2" +) + +const ( + tenant = "11111111-1111-1111-1111-111111111111" + terminalApp = "bbbbbbbb-0000-0000-0000-000000000002" +) + +var token = auth.Token{Issuer: "https://login.microsoftonline.com/" + tenant + "/v2.0", Subject: "pairwise", Expiry: time.Unix(2000000000, 0)} + +func testConfig() Config { + return Config{TenantIds: []string{tenant}, ApiClientId: "api", RequiredUserScope: "access_as_user"}.withDefaults() +} + +func appClaims() claims { + return claims{TenantId: tenant, ObjectId: "sp-oid", AuthorizedParty: terminalApp, IdentityType: "app", Roles: []string{"Playbook.Execute"}} +} + +func userClaims() claims { + return claims{TenantId: tenant, ObjectId: "user-oid", AuthorizedParty: "gui", Scope: "openid access_as_user", + Roles: []string{"Playbook.Read"}, Name: "Alice", PreferredUsername: "alice@example.com"} +} + +func TestAppToken(t *testing.T) { + principal, err := testConfig().principal(tenant, token, appClaims()) + + assert.Equal(t, err, nil) + assert.Equal(t, principal.Kind, auth.KindApp) + assert.Equal(t, principal.ClientId, terminalApp) + assert.Equal(t, principal.Subject, "sp-oid") + assert.Equal(t, principal.Self(), "entra:"+tenant+":app:"+terminalApp) + assert.Equal(t, principal.Permissions, map[roles.Permission]bool{roles.PlaybookRead: true, roles.PlaybookExecute: true}) + assert.Equal(t, principal.ExpiresAt, token.Expiry) +} + +func TestUserToken(t *testing.T) { + principal, err := testConfig().principal(tenant, token, userClaims()) + + assert.Equal(t, err, nil) + assert.Equal(t, principal.Kind, auth.KindUser) + assert.Equal(t, principal.Name, "Alice") + assert.Equal(t, principal.Email, "alice@example.com") + assert.Equal(t, principal.Self(), "entra:"+tenant+":user:user-oid") + assert.Equal(t, principal.Has(roles.PlaybookRead), true) + assert.Equal(t, principal.Has(roles.PlaybookExecute), false) +} + +func TestKindWithoutIdtypClaim(t *testing.T) { + app := appClaims() + app.IdentityType = "" + user := userClaims() + + assert.Equal(t, app.kind(), auth.KindApp) + assert.Equal(t, user.kind(), auth.KindUser) +} + +func TestV1ClientIdComesFromAppid(t *testing.T) { + v1 := appClaims() + v1.AuthorizedParty = "" + v1.AppId = terminalApp + + principal, err := testConfig().principal(tenant, token, v1) + + assert.Equal(t, err, nil) + assert.Equal(t, principal.ClientId, terminalApp) +} + +func TestTenantMustMatchIssuer(t *testing.T) { + foreign := appClaims() + foreign.TenantId = "22222222-2222-2222-2222-222222222222" + + _, err := testConfig().principal(tenant, token, foreign) + + assert.NotEqual(t, err, nil) +} + +func TestAllowedClientIds(t *testing.T) { + config := testConfig() + config.AllowedClientIds = []string{terminalApp} + other := appClaims() + other.AuthorizedParty = "someone-else" + + _, err := config.principal(tenant, token, appClaims()) + assert.Equal(t, err, nil) + _, err = config.principal(tenant, token, other) + assert.NotEqual(t, err, nil) +} + +func TestUserTokenNeedsRequiredScope(t *testing.T) { + wrongScope := userClaims() + wrongScope.Scope = "User.Read" + + _, err := testConfig().principal(tenant, token, wrongScope) + assert.NotEqual(t, err, nil) + + config := testConfig() + config.RequiredUserScope = "" + _, err = config.principal(tenant, token, wrongScope) + assert.Equal(t, err, nil) +} + +func TestGroupsBecomeSubjects(t *testing.T) { + user := userClaims() + user.Groups = []string{"G-Blue", "g-red"} + + principal, err := testConfig().principal(tenant, token, user) + + assert.Equal(t, err, nil) + assert.Equal(t, principal.Subjects, []string{ + "entra:" + tenant + ":user:user-oid", + "entra:" + tenant + ":group:g-blue", + "entra:" + tenant + ":group:g-red", + }) + assert.Equal(t, principal.GroupsIncomplete, false) +} + +func TestGroupOverflowIsFlagged(t *testing.T) { + user := userClaims() + user.ClaimNames = map[string]string{"groups": "src1"} + + principal, err := testConfig().principal(tenant, token, user) + + assert.Equal(t, err, nil) + assert.Equal(t, principal.GroupsIncomplete, true) +} + +func TestNoRolesNoPermissions(t *testing.T) { + app := appClaims() + app.Roles = nil + + principal, err := testConfig().principal(tenant, token, app) + + assert.Equal(t, err, nil) + assert.Equal(t, len(principal.Permissions), 0) +} diff --git a/pkg/auth/providers/entra/config.go b/pkg/auth/providers/entra/config.go new file mode 100644 index 00000000..564366a9 --- /dev/null +++ b/pkg/auth/providers/entra/config.go @@ -0,0 +1,63 @@ +// Package entra verifies access tokens issued by Microsoft Entra ID for the +// SOARCA API app registration. +package entra + +import ( + "errors" + "strings" + + "soarca/pkg/auth/roles" +) + +// DefaultAuthority is the Entra ID login host for the public cloud. +const DefaultAuthority = "https://login.microsoftonline.com" + +const v1IssuerBase = "https://sts.windows.net" + +// Config describes the app registration that represents the SOARCA API +// ("Expose an API"). Callers must request tokens for this API; tokens for +// Microsoft Graph cannot be verified by anyone but Microsoft. +type Config struct { + // TenantIds that may call SOARCA. + TenantIds []string + // ApiClientId is the application (client) ID of the SOARCA API app + // registration. v2 access tokens carry it as their audience. + ApiClientId string + // ApiAppIdUri is the Application ID URI, e.g. "api://soarca". v1 access + // tokens carry it as their audience. Optional. + ApiAppIdUri string + // AllowedClientIds restricts which applications may call SOARCA. + // Empty allows any client in an allowed tenant. + AllowedClientIds []string + // RequiredUserScope is the delegated scope user tokens must carry, + // e.g. "access_as_user". Empty skips the check. + RequiredUserScope string + // AcceptV1Tokens also accepts tokens from https://sts.windows.net//. + AcceptV1Tokens bool + // Authority is the login host. Empty means DefaultAuthority. + Authority string + // Roles maps app-role values to permissions. Nil means + // DefaultRoles(). + Roles roles.RoleMap +} + +func (config Config) withDefaults() Config { + if config.Authority == "" { + config.Authority = DefaultAuthority + } + config.Authority = strings.TrimRight(config.Authority, "/") + if config.Roles == nil { + config.Roles = DefaultRoles() + } + return config +} + +func (config Config) validate() error { + if len(config.TenantIds) == 0 { + return errors.New("entra: at least one tenant ID is required") + } + if config.ApiClientId == "" { + return errors.New("entra: the SOARCA API client ID is required") + } + return nil +} diff --git a/pkg/auth/providers/entra/provider.go b/pkg/auth/providers/entra/provider.go new file mode 100644 index 00000000..675921b7 --- /dev/null +++ b/pkg/auth/providers/entra/provider.go @@ -0,0 +1,84 @@ +package entra + +import ( + "context" + "fmt" + "sort" + + "soarca/pkg/auth" + + "github.com/coreos/go-oidc/v3/oidc" +) + +// Name labels this provider in principals and logs. +const Name = "entra" + +// Provider verifies Entra ID access tokens for the configured tenants. +type Provider struct { + config Config + tenants map[string]tenantValidator // keyed by issuer +} + +type tenantValidator struct { + tenantId string + validator *auth.TokenValidator +} + +// New builds a Provider. It doesn't contact Entra ID; signing keys are +// fetched on first use and cached. +func New(config Config) (*Provider, error) { + if err := config.validate(); err != nil { + return nil, err + } + config = config.withDefaults() + audiences := []string{config.ApiClientId, config.ApiAppIdUri} + + provider := &Provider{config: config, tenants: map[string]tenantValidator{}} + for _, tenantId := range config.TenantIds { + // go-oidc keeps this context for later key refreshes, so it must + // outlive any request. + keys := oidc.NewRemoteKeySet(context.Background(), fmt.Sprintf("%s/%s/discovery/v2.0/keys", config.Authority, tenantId)) + issuers := []string{fmt.Sprintf("%s/%s/v2.0", config.Authority, tenantId)} + if config.AcceptV1Tokens { + issuers = append(issuers, fmt.Sprintf("%s/%s/", v1IssuerBase, tenantId)) + } + for _, issuer := range issuers { + provider.tenants[issuer] = tenantValidator{ + tenantId: tenantId, + validator: auth.NewTokenValidator(issuer, keys, audiences, []string{oidc.RS256}), + } + } + } + return provider, nil +} + +func (provider *Provider) Name() string { + return Name +} + +// Handles declares the issuer of every allowed tenant. +func (provider *Provider) Handles() auth.Match { + issuers := make([]string, 0, len(provider.tenants)) + for issuer := range provider.tenants { + issuers = append(issuers, issuer) + } + sort.Strings(issuers) + return auth.Match{Issuers: issuers} +} + +// Authenticate verifies the token and maps it to a Principal. +func (provider *Provider) Authenticate(ctx context.Context, credential auth.Credential) (*auth.Principal, error) { + tenant, ok := provider.tenants[credential.Issuer] + if !ok { + return nil, fmt.Errorf("issuer %q is not an allowed tenant", credential.Issuer) + } + validated, err := tenant.validator.Validate(ctx, credential.Raw) + if err != nil { + return nil, err + } + var tokenClaims claims + if err := validated.Claims(&tokenClaims); err != nil { + return nil, err + } + return provider.config.principal(tenant.tenantId, validated, tokenClaims) +} diff --git a/pkg/auth/providers/entra/provider_test.go b/pkg/auth/providers/entra/provider_test.go new file mode 100644 index 00000000..1892fc03 --- /dev/null +++ b/pkg/auth/providers/entra/provider_test.go @@ -0,0 +1,92 @@ +package entra + +import ( + "context" + "testing" + + "soarca/pkg/auth" + "soarca/test/unittest/fakes/fake_identity_provider" + + "github.com/go-playground/assert/v2" +) + +const otherTenant = "22222222-2222-2222-2222-222222222222" + +// fakeEntra serves key sets for tenant and otherTenant at Entra's paths. +func fakeEntra(t *testing.T) *fake_identity_provider.IdentityProvider { + server := fake_identity_provider.New(t, false) + server.ServeKeys("/" + tenant + "/discovery/v2.0/keys") + server.ServeKeys("/" + otherTenant + "/discovery/v2.0/keys") + return server +} + +func newTestProvider(t *testing.T, server *fake_identity_provider.IdentityProvider, acceptV1 bool) *Provider { + provider, err := New(Config{ + TenantIds: []string{tenant}, + ApiClientId: "api-client-id", + ApiAppIdUri: "api://soarca", + AcceptV1Tokens: acceptV1, + Authority: server.Url(""), + }) + assert.Equal(t, err, nil) + return provider +} + +func TestNameAndHandles(t *testing.T) { + server := fakeEntra(t) + + assert.Equal(t, newTestProvider(t, server, false).Name(), "entra") + assert.Equal(t, newTestProvider(t, server, false).Handles(), auth.Match{Issuers: []string{server.Url("/" + tenant + "/v2.0")}}) + assert.Equal(t, newTestProvider(t, server, true).Handles(), auth.Match{Issuers: []string{ + server.Url("/" + tenant + "/v2.0"), + "https://sts.windows.net/" + tenant + "/", + }}) +} + +func TestAuthenticateSignedTokens(t *testing.T) { + server := fakeEntra(t) + provider := newTestProvider(t, server, true) + appToken := func(issuer string, audience string) auth.Credential { + return auth.Credential{Issuer: issuer, Raw: server.Sign(t, map[string]any{ + "iss": issuer, "aud": audience, "tid": tenant, "azp": "caller", "idtyp": "app", "roles": []string{"Playbook.Read"}, + })} + } + v2Issuer := server.Url("/" + tenant + "/v2.0") + otherIssuer := server.Url("/" + otherTenant + "/v2.0") + + accepted := map[string]auth.Credential{ + "v2 token": appToken(v2Issuer, "api-client-id"), + "app id uri as audience": appToken(v2Issuer, "api://soarca"), + "v1 token when accepted": appToken("https://sts.windows.net/"+tenant+"/", "api://soarca"), + } + for name, credential := range accepted { + principal, err := provider.Authenticate(context.Background(), credential) + if err != nil { + t.Errorf("%s: %v", name, err) + continue + } + assert.Equal(t, principal.Kind, auth.KindApp) + } + + rejected := map[string]auth.Credential{ + "graph token": appToken(v2Issuer, "https://graph.microsoft.com"), + "tenant not allowed": appToken(otherIssuer, "api-client-id"), + "issuer differs from routing": {Issuer: v2Issuer, Raw: appToken(otherIssuer, "api-client-id").Raw}, + "unpublished key": {Issuer: v2Issuer, Raw: server.SignWithUnknownKey(t, map[string]any{ + "iss": v2Issuer, "aud": "api-client-id", "tid": tenant, "roles": []string{"SOARCA.Admin"}, + })}, + } + for name, credential := range rejected { + if _, err := provider.Authenticate(context.Background(), credential); err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +func TestNewRejectsIncompleteConfig(t *testing.T) { + _, err := New(Config{ApiClientId: "api"}) + assert.NotEqual(t, err, nil) + + _, err = New(Config{TenantIds: []string{tenant}}) + assert.NotEqual(t, err, nil) +} diff --git a/pkg/auth/providers/entra/roles.go b/pkg/auth/providers/entra/roles.go new file mode 100644 index 00000000..eeb7950e --- /dev/null +++ b/pkg/auth/providers/entra/roles.go @@ -0,0 +1,14 @@ +package entra + +import "soarca/pkg/auth/roles" + +// DefaultRoles maps the app-role values defined on the SOARCA API app +// registration in Entra ID to SOARCA permissions. +func DefaultRoles() roles.RoleMap { + return roles.RoleMap{ + "Playbook.Read": {roles.PlaybookRead}, + "Playbook.Write": {roles.PlaybookRead, roles.PlaybookWrite}, + "Playbook.Execute": {roles.PlaybookRead, roles.PlaybookExecute}, + "SOARCA.Admin": roles.All(), + } +} diff --git a/pkg/auth/providers/entra/roles_test.go b/pkg/auth/providers/entra/roles_test.go new file mode 100644 index 00000000..06431ade --- /dev/null +++ b/pkg/auth/providers/entra/roles_test.go @@ -0,0 +1,23 @@ +package entra + +import ( + "testing" + + "soarca/pkg/auth/roles" + + "github.com/go-playground/assert/v2" +) + +func TestDefaultRoles(t *testing.T) { + defaults := DefaultRoles() + + assert.Equal(t, defaults.Permissions([]string{"Playbook.Read"}), map[roles.Permission]bool{roles.PlaybookRead: true}) + assert.Equal(t, defaults.Permissions([]string{"Playbook.Write"}), map[roles.Permission]bool{roles.PlaybookRead: true, roles.PlaybookWrite: true}) + assert.Equal(t, defaults.Permissions([]string{"Playbook.Execute"}), map[roles.Permission]bool{roles.PlaybookRead: true, roles.PlaybookExecute: true}) + assert.Equal(t, len(defaults.Permissions([]string{"SOARCA.Admin"})), len(roles.All())) +} + +func TestDefaultRolesAreCaseSensitive(t *testing.T) { + // Entra sends app-role values exactly as defined on the registration. + assert.Equal(t, len(DefaultRoles().Permissions([]string{"playbook.read"})), 0) +} From 948f2e9777c5219d2f8e324ba244f0bde7832cd7 Mon Sep 17 00:00:00 2001 From: Maarten de Kruijf <16100232+MaartendeKruijf@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:44:32 +0200 Subject: [PATCH 06/10] Added auth API middleware --- pkg/api/auth/auth_middleware.go | 104 +++++++++++++++++++++++ pkg/api/auth/auth_middleware_test.go | 120 +++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 pkg/api/auth/auth_middleware.go create mode 100644 pkg/api/auth/auth_middleware_test.go diff --git a/pkg/api/auth/auth_middleware.go b/pkg/api/auth/auth_middleware.go new file mode 100644 index 00000000..d39176d9 --- /dev/null +++ b/pkg/api/auth/auth_middleware.go @@ -0,0 +1,104 @@ +package auth + +import ( + "fmt" + "net/http" + "reflect" + "strings" + + "soarca/internal/logger" + apiError "soarca/pkg/api/error" + "soarca/pkg/auth" + "soarca/pkg/auth/roles" + + "github.com/gin-gonic/gin" +) + +var log *logger.Log + +type Empty struct{} + +func init() { + log = logger.Logger(reflect.TypeOf(Empty{}).PkgPath(), logger.Info, "", logger.Json) +} + +const principalKey = "soarca.auth.principal" + +// Middleware authenticates each request's bearer credential and stores the +// caller for PrincipalFromContext. It responds 401 when that fails. +func Middleware(authenticator auth.IAuthenticator) gin.HandlerFunc { + return func(ginContext *gin.Context) { + credential := bearerCredential(ginContext.GetHeader("Authorization")) + principal, err := authenticator.Authenticate(ginContext.Request.Context(), credential) + if err != nil { + log.Debug(fmt.Sprintf("authentication failed for %s: %s", ginContext.FullPath(), err)) + ginContext.Header("WWW-Authenticate", `Bearer realm="soarca"`) + apiError.SendErrorResponse(ginContext, http.StatusUnauthorized, "unauthenticated", originalCall(ginContext), "") + ginContext.Abort() + return + } + ginContext.Set(principalKey, principal) + ginContext.Next() + } +} + +// RequireAll lets a request through only if the caller holds every permission. +func RequireAll(permissions ...roles.Permission) gin.HandlerFunc { + return func(ginContext *gin.Context) { + principal := PrincipalFromContext(ginContext) + for _, permission := range permissions { + if !principal.Has(permission) { + deny(ginContext, principal, string(permission)) + return + } + } + ginContext.Next() + } +} + +// RequireAny lets a request through if the caller holds at least one permission. +func RequireAny(permissions ...roles.Permission) gin.HandlerFunc { + return func(ginContext *gin.Context) { + principal := PrincipalFromContext(ginContext) + names := make([]string, 0, len(permissions)) + for _, permission := range permissions { + if principal.Has(permission) { + ginContext.Next() + return + } + names = append(names, string(permission)) + } + deny(ginContext, principal, strings.Join(names, " or ")) + } +} + +// PrincipalFromContext returns the caller, or nil when Middleware didn't run. +func PrincipalFromContext(ginContext *gin.Context) *auth.Principal { + value, _ := ginContext.Get(principalKey) + principal, _ := value.(*auth.Principal) + return principal +} + +func deny(ginContext *gin.Context, principal *auth.Principal, required string) { + if principal == nil { + apiError.SendErrorResponse(ginContext, http.StatusUnauthorized, "unauthenticated", originalCall(ginContext), "") + ginContext.Abort() + return + } + log.Info(fmt.Sprintf("permission denied: %s (client %q) needs %s for %s", + principal.Self(), principal.ClientId, required, ginContext.FullPath())) + apiError.SendErrorResponse(ginContext, http.StatusForbidden, "forbidden: requires "+required, originalCall(ginContext), "") + ginContext.Abort() +} + +func originalCall(ginContext *gin.Context) string { + return ginContext.Request.Method + " " + ginContext.Request.URL.Path +} + +func bearerCredential(header string) string { + const scheme = "bearer " + if len(header) > len(scheme) && strings.EqualFold(header[:len(scheme)], scheme) { + return strings.TrimSpace(header[len(scheme):]) + } + return "" +} diff --git a/pkg/api/auth/auth_middleware_test.go b/pkg/api/auth/auth_middleware_test.go new file mode 100644 index 00000000..7da4468b --- /dev/null +++ b/pkg/api/auth/auth_middleware_test.go @@ -0,0 +1,120 @@ +package auth + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "soarca/pkg/auth" + "soarca/pkg/auth/roles" + "soarca/test/unittest/mocks/mock_auth" + + "github.com/gin-gonic/gin" + "github.com/go-playground/assert/v2" + "github.com/stretchr/testify/mock" +) + +var executor = &auth.Principal{ + Subjects: []string{"entra:tenant:app:terminal"}, + Permissions: map[roles.Permission]bool{roles.PlaybookRead: true, roles.PlaybookExecute: true}, +} + +var reader = &auth.Principal{ + Subjects: []string{"authentik:user:reader"}, + Permissions: map[roles.Permission]bool{roles.PlaybookRead: true}, +} + +func newRouter(authenticator auth.IAuthenticator) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + routes := router.Group("/", Middleware(authenticator)) + routes.GET("/playbook/", RequireAll(roles.PlaybookRead), func(ginContext *gin.Context) { + ginContext.String(http.StatusOK, PrincipalFromContext(ginContext).Self()) + }) + routes.POST("/trigger/playbook", RequireAll(roles.PlaybookExecute), func(ginContext *gin.Context) { + ginContext.Status(http.StatusOK) + }) + routes.GET("/either", RequireAny(roles.PlaybookWrite, roles.PlaybookExecute), func(ginContext *gin.Context) { + ginContext.Status(http.StatusOK) + }) + return router +} + +func serve(router *gin.Engine, method string, path string, authorization string) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, path, nil) + if authorization != "" { + request.Header.Set("Authorization", authorization) + } + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + return recorder +} + +func authenticatorFor(credentials map[string]*auth.Principal) *mock_auth.MockAuthenticator { + authenticator := &mock_auth.MockAuthenticator{} + for credential, principal := range credentials { + authenticator.On("Authenticate", mock.Anything, credential).Return(principal, nil) + } + authenticator.On("Authenticate", mock.Anything, mock.Anything).Return(nil, auth.ErrUnauthenticated) + return authenticator +} + +func TestPermissionsDecideAccess(t *testing.T) { + router := newRouter(authenticatorFor(map[string]*auth.Principal{"executor": executor, "reader": reader})) + + assert.Equal(t, serve(router, http.MethodPost, "/trigger/playbook", "Bearer executor").Code, http.StatusOK) + assert.Equal(t, serve(router, http.MethodGet, "/playbook/", "Bearer executor").Code, http.StatusOK) + assert.Equal(t, serve(router, http.MethodPost, "/trigger/playbook", "Bearer reader").Code, http.StatusForbidden) + assert.Equal(t, serve(router, http.MethodGet, "/either", "Bearer executor").Code, http.StatusOK) + assert.Equal(t, serve(router, http.MethodGet, "/either", "Bearer reader").Code, http.StatusForbidden) +} + +func TestHandlersSeeThePrincipal(t *testing.T) { + router := newRouter(authenticatorFor(map[string]*auth.Principal{"reader": reader})) + + assert.Equal(t, serve(router, http.MethodGet, "/playbook/", "Bearer reader").Body.String(), "authentik:user:reader") +} + +func TestFailedAuthenticationIs401(t *testing.T) { + authenticator := &mock_auth.MockAuthenticator{} + authenticator.On("Authenticate", mock.Anything, "bad").Return(nil, errors.New("bad signature")) + authenticator.On("Authenticate", mock.Anything, "").Return(nil, auth.ErrUnauthenticated) + router := newRouter(authenticator) + + assert.Equal(t, serve(router, http.MethodGet, "/playbook/", "Bearer bad").Code, http.StatusUnauthorized) + assert.Equal(t, serve(router, http.MethodGet, "/playbook/", "").Code, http.StatusUnauthorized) + assert.Equal(t, serve(router, http.MethodGet, "/playbook/", "Basic dXNlcjpwYXNz").Code, http.StatusUnauthorized) +} + +func TestErrorUsesTheSoarcaErrorFormat(t *testing.T) { + router := newRouter(authenticatorFor(nil)) + + recorder := serve(router, http.MethodGet, "/playbook/", "") + + assert.Equal(t, recorder.Header().Get("WWW-Authenticate"), `Bearer realm="soarca"`) + var body map[string]string + assert.Equal(t, json.Unmarshal(recorder.Body.Bytes(), &body), nil) + assert.Equal(t, body["status"], "401") + assert.Equal(t, body["message"], "unauthenticated") + assert.Equal(t, body["original-call"], "GET /playbook/") +} + +func TestRequireWithoutMiddlewareIs401(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/playbook/", RequireAll(roles.PlaybookRead), func(ginContext *gin.Context) { + ginContext.Status(http.StatusOK) + }) + + assert.Equal(t, serve(router, http.MethodGet, "/playbook/", "").Code, http.StatusUnauthorized) +} + +func TestBearerCredential(t *testing.T) { + assert.Equal(t, bearerCredential("Bearer abc"), "abc") + assert.Equal(t, bearerCredential("bearer abc "), "abc") + assert.Equal(t, bearerCredential("Basic abc"), "") + assert.Equal(t, bearerCredential("Bearer"), "") + assert.Equal(t, bearerCredential(""), "") +} From 09e0151d957735e69225bf49326f7e1b648195a9 Mon Sep 17 00:00:00 2001 From: Maarten de Kruijf <16100232+MaartendeKruijf@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:44:53 +0200 Subject: [PATCH 07/10] Update config to accept new auth configuration --- internal/config/config.go | 82 +++++++++++++++++++++++++++------- internal/config/config_test.go | 55 +++++++++++++++++++++++ 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 17077b2d..4ba91638 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,9 +36,7 @@ type Server struct { EnableTls bool `env:"ENABLE_TLS" envDefault:"false"` CertFile string `env:"CERT_FILE" envDefault:"./certs/server.crt"` CertKeyFile string `env:"CERT_KEY_FILE" envDefault:"./certs/server.key"` - // Read by gin itself during package init; kept here so it is documented - // and can be applied explicitly with gin.SetMode. - GinMode string `env:"GIN_MODE" envDefault:"release"` + GinMode string `env:"GIN_MODE" envDefault:"release"` } type Database struct { @@ -70,12 +68,59 @@ type TheHive struct { Reporter bool `env:"THEHIVE_REPORTER" envDefault:"false"` } -// Auth mirrors what github.com/COSSAS/gauth DefaultConfig() reads. +// Auth configures who may call the API. With AUTH_ENABLED=true, at least one +// identity provider must be configured. The OIDC_* settings are the ones +// gauth used. type Auth struct { Enabled bool `env:"AUTH_ENABLED" envDefault:"false"` Issuer string `env:"OIDC_ISSUER"` ClientId string `env:"OIDC_CLIENT_ID"` SkipTlsVerify bool `env:"OIDC_SKIP_TLS_VERIFY" envDefault:"false"` + // Members of AUTH_GROUP get every permission, as they did with gauth. + AdminGroup string `env:"AUTH_GROUP" envDefault:"soarca_admin"` + Entra Entra + // JSON file with hashed static API keys. Empty disables API keys. + ApiKeysFile string `env:"AUTH_API_KEYS_FILE"` +} + +// Entra configures Microsoft Entra ID; it's enabled when tenants are set. +type Entra struct { + TenantIds []string `env:"AUTH_ENTRA_TENANT_IDS" envSeparator:","` + ApiClientId string `env:"AUTH_ENTRA_API_CLIENT_ID"` + ApiAppIdUri string `env:"AUTH_ENTRA_API_APP_ID_URI"` + AllowedClientIds []string `env:"AUTH_ENTRA_ALLOWED_CLIENT_IDS" envSeparator:","` + RequiredUserScope string `env:"AUTH_ENTRA_REQUIRED_USER_SCOPE" envDefault:"access_as_user"` + AcceptV1Tokens bool `env:"AUTH_ENTRA_ACCEPT_V1" envDefault:"false"` + Authority string `env:"AUTH_ENTRA_AUTHORITY" envDefault:"https://login.microsoftonline.com"` +} + +// OidcEnabled reports whether a generic OIDC provider is configured. +func (auth Auth) OidcEnabled() bool { + return auth.Issuer != "" || auth.ClientId != "" +} + +// EntraEnabled reports whether Entra ID is configured. +func (auth Auth) EntraEnabled() bool { + return len(auth.Entra.TenantIds) > 0 || auth.Entra.ApiClientId != "" +} + +// validate only applies with AUTH_ENABLED=true: the shipped docker-compose.yaml +// sets OIDC_CLIENT_ID with auth disabled, and that must keep starting. +func (auth Auth) validate() []error { + if !auth.Enabled { + return nil + } + var errs []error + if auth.OidcEnabled() && (auth.Issuer == "" || auth.ClientId == "") { + errs = append(errs, errors.New("OIDC_ISSUER and OIDC_CLIENT_ID must be set together")) + } + if auth.EntraEnabled() && (len(auth.Entra.TenantIds) == 0 || auth.Entra.ApiClientId == "") { + errs = append(errs, errors.New("AUTH_ENTRA_TENANT_IDS and AUTH_ENTRA_API_CLIENT_ID must be set together")) + } + if !auth.OidcEnabled() && !auth.EntraEnabled() && auth.ApiKeysFile == "" { + errs = append(errs, errors.New("AUTH_ENABLED=true requires OIDC_ISSUER, AUTH_ENTRA_TENANT_IDS or AUTH_API_KEYS_FILE")) + } + return errs } type SshKms struct { @@ -86,14 +131,10 @@ type SshKms struct { // Dynamic is everything that may be changed while SOARCA runs. Secrets never // go here: this struct is what an API handler reads and writes. type Dynamic struct { - // Applied with logrus SetLevel on live loggers when changed. - LogLevel string `env:"LOG_GLOBAL_LEVEL" envDefault:"info" json:"log_level"` - // Read every time a decomposer is built (per trigger/playbook action). - HttpSkipCertValidation bool `env:"HTTP_SKIP_CERT_VALIDATION" envDefault:"false" json:"http_skip_cert_validation"` - // Read on every CACAO v2 validation. Empty means use the embedded schema. - ValidationSchemaUrl string `env:"VALIDATION_SCHEMA_URL" json:"validation_schema_url"` - // Needs the CORS middleware to read config per request instead of once. - AllowedOrigins []string `env:"SOARCA_ALLOWED_ORIGINS" envSeparator:"," envDefault:"*" json:"allowed_origins"` + LogLevel string `env:"LOG_GLOBAL_LEVEL" envDefault:"info" json:"log_level"` + HttpSkipCertValidation bool `env:"HTTP_SKIP_CERT_VALIDATION" envDefault:"false" json:"http_skip_cert_validation"` + ValidationSchemaUrl string `env:"VALIDATION_SCHEMA_URL" json:"validation_schema_url"` + AllowedOrigins []string `env:"SOARCA_ALLOWED_ORIGINS" envSeparator:"," envDefault:"*" json:"allowed_origins"` } var validLogLevels = map[string]bool{ @@ -110,9 +151,7 @@ func (static Static) Validate() error { if database.Enabled && (database.Uri == "" || database.Username == "" || database.Password == "") { errs = append(errs, errors.New("DATABASE=true requires MONGODB_URI, DB_USERNAME and DB_PASSWORD")) } - if static.Auth.Enabled && (static.Auth.Issuer == "" || static.Auth.ClientId == "") { - errs = append(errs, errors.New("AUTH_ENABLED=true requires OIDC_ISSUER and OIDC_CLIENT_ID")) - } + errs = append(errs, static.Auth.validate()...) if static.MaxExecutions < 1 { errs = append(errs, errors.New("MAX_EXECUTIONS must be at least 1")) } @@ -170,6 +209,8 @@ func Load() (*Config, error) { if err := env.Parse(&static); err != nil { return nil, err } + static.Auth.Entra.TenantIds = trimList(static.Auth.Entra.TenantIds) + static.Auth.Entra.AllowedClientIds = trimList(static.Auth.Entra.AllowedClientIds) if err := static.Validate(); err != nil { return nil, err } @@ -190,6 +231,17 @@ func Load() (*Config, error) { return configuration, nil } +// trimList trims each item of a comma-separated setting and drops empty ones. +func trimList(items []string) []string { + var trimmed []string + for _, item := range items { + if item = strings.TrimSpace(item); item != "" { + trimmed = append(trimmed, item) + } + } + return trimmed +} + // Dynamic returns a copy of the current runtime settings. Call it at the // point of use; don't store the result. func (configuration *Config) Dynamic() Dynamic { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c805198b..9cd1772f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -148,3 +148,58 @@ func TestConcurrentUpdatesToDifferentFieldsBothSurvive(t *testing.T) { assert.Equal(t, config.Dynamic().LogLevel, "debug") assert.Equal(t, config.Dynamic().AllowedOrigins, []string{"https://gui.example"}) } + +func TestLoadAcceptsExistingGauthSettings(t *testing.T) { + t.Setenv("AUTH_ENABLED", "true") + t.Setenv("OIDC_ISSUER", "https://authentik.example/application/o/soarca/") + t.Setenv("OIDC_CLIENT_ID", "soarca") + + config, err := Load() + assert.Equal(t, err, nil) + assert.Equal(t, config.Static.Auth.OidcEnabled(), true) + assert.Equal(t, config.Static.Auth.EntraEnabled(), false) + assert.Equal(t, config.Static.Auth.AdminGroup, "soarca_admin") +} + +func TestLoadReadsEntraSettings(t *testing.T) { + t.Setenv("AUTH_ENABLED", "true") + t.Setenv("AUTH_ENTRA_TENANT_IDS", " tenant-a, tenant-b,, ") + t.Setenv("AUTH_ENTRA_API_CLIENT_ID", "api") + t.Setenv("AUTH_ENTRA_REQUIRED_USER_SCOPE", "") + + config, err := Load() + assert.Equal(t, err, nil) + entra := config.Static.Auth.Entra + assert.Equal(t, entra.TenantIds, []string{"tenant-a", "tenant-b"}) + assert.Equal(t, entra.RequiredUserScope, "access_as_user") + assert.Equal(t, entra.Authority, "https://login.microsoftonline.com") +} + +func TestLoadAcceptsApiKeysAsOnlyProvider(t *testing.T) { + t.Setenv("AUTH_ENABLED", "true") + t.Setenv("AUTH_API_KEYS_FILE", "/etc/soarca/keys.json") + + _, err := Load() + assert.Equal(t, err, nil) +} + +func TestLoadRejectsHalfConfiguredProviders(t *testing.T) { + t.Setenv("AUTH_ENABLED", "true") + t.Setenv("OIDC_ISSUER", "https://authentik.example/application/o/soarca/") + _, err := Load() + assert.NotEqual(t, err, nil) + + t.Setenv("OIDC_ISSUER", "") + t.Setenv("AUTH_ENTRA_TENANT_IDS", "tenant-a") + _, err = Load() + assert.NotEqual(t, err, nil) +} + +func TestLoadIgnoresAuthSettingsWhenAuthDisabled(t *testing.T) { + // The shipped docker-compose.yaml sets OIDC_CLIENT_ID with auth disabled. + t.Setenv("AUTH_ENABLED", "false") + t.Setenv("OIDC_CLIENT_ID", "some client ID") + + _, err := Load() + assert.Equal(t, err, nil) +} From e6e4009847c8af622640819e7305658b2ec82c56 Mon Sep 17 00:00:00 2001 From: Maarten de Kruijf <16100232+MaartendeKruijf@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:38:25 +0200 Subject: [PATCH 08/10] Added authentication documentation --- .env.example | 60 ++++-- .../docs/installation-configuration/_index.md | 27 ++- .../authentication.md | 158 ---------------- .../authentication/_index.md | 146 +++++++++++++++ .../authentication/api-keys.md | 67 +++++++ .../authentication/authentik.md | 142 ++++++++++++++ .../authentication/entra.md | 175 ++++++++++++++++++ docs/content/en/docs/soarca-gui/_index.md | 2 +- 8 files changed, 598 insertions(+), 179 deletions(-) delete mode 100644 docs/content/en/docs/installation-configuration/authentication.md create mode 100644 docs/content/en/docs/installation-configuration/authentication/_index.md create mode 100644 docs/content/en/docs/installation-configuration/authentication/api-keys.md create mode 100644 docs/content/en/docs/installation-configuration/authentication/authentik.md create mode 100644 docs/content/en/docs/installation-configuration/authentication/entra.md diff --git a/.env.example b/.env.example index c69ca0fe..d0157ba4 100644 --- a/.env.example +++ b/.env.example @@ -1,42 +1,72 @@ +# SOARCA settings. Every variable SOARCA reads is listed here; see +# internal/config/config.go for the defaults and +# https://cossas.github.io/SOARCA/docs/installation-configuration/ for details. + +### Server PORT: 8080 ENABLE_TLS: false CERT_FILE: "/certs/server.crt" CERT_KEY_FILE: "/certs/server.key" -MAX_EXECUTIONS=1000 -SOARCA_ALLOWED_ORIGINS: "*" GIN_MODE: "release" +# Comma-separated, or "*" for any origin. +SOARCA_ALLOWED_ORIGINS: "*" + +### Execution +MAX_EXECUTIONS: 10 +MAX_REPORTERS: 5 +# Empty uses the CACAO v2 schema embedded in SOARCA. +VALIDATION_SCHEMA_URL: "" +HTTP_SKIP_CERT_VALIDATION: false + +### Database +DATABASE: false MONGODB_URI: "mongodb://localhost:27017" -DATABASE_NAME: "soarca" DB_USERNAME: "root" DB_PASSWORD: "rootpassword" -PLAYBOOK_API_LOG_LEVEL: trace -DATABASE: "false" -MAX_REPORTERS: "5" +### Logging LOG_GLOBAL_LEVEL: "info" LOG_MODE: "development" LOG_FILE_PATH: "" LOG_FORMAT: "json" +### Fins (MQTT) ENABLE_FINS: false MQTT_BROKER: "localhost" MQTT_PORT: 1883 -HTTP_SKIP_CERT_VALIDATION: false ### Integrations # The Hive THEHIVE_ACTIVATE: false -THEHIVE_API_TOKEN: your_token -THEHIVE_API_BASE_URL: http://your.thehive.instance/api/v1/ +THEHIVE_API_TOKEN: "your_token" +THEHIVE_API_BASE_URL: "http://your.thehive.instance/api/v1/" +THEHIVE_ALLOW_INSECURE: true +THEHIVE_REPORTER: false + +### Authentication +# With AUTH_ENABLED: true, configure at least one provider below. +AUTH_ENABLED: false -# Authentication -AUTH_ENABLED: false -OIDC_PROVIDER: "https://authentikuri:9443/application/o/soarca/" +# OpenID Connect provider, e.g. authentik +OIDC_ISSUER: "https://authentikuri:9443/application/o/soarca/" OIDC_CLIENT_ID: "some client ID" OIDC_SKIP_TLS_VERIFY: false +# Members of this group get every permission. +AUTH_GROUP: "soarca_admin" -# SSH Key Management -ENABLE_SSH_KMS: false -SSH_KMS_DIR: "deployments/docker/testing/ssh-kms-test/ssh-keystore/" +# Microsoft Entra ID +# AUTH_ENTRA_TENANT_IDS: "," +# AUTH_ENTRA_API_CLIENT_ID: "" +# AUTH_ENTRA_API_APP_ID_URI: "api://soarca" +# AUTH_ENTRA_ALLOWED_CLIENT_IDS: "," +# AUTH_ENTRA_REQUIRED_USER_SCOPE: "access_as_user" +# AUTH_ENTRA_ACCEPT_V1: false +# AUTH_ENTRA_AUTHORITY: "https://login.microsoftonline.com" +# Static API keys +# AUTH_API_KEYS_FILE: "/etc/soarca/api-keys.json" + +### SSH key management +ENABLE_SSH_KMS: false +SSH_KMS_DIR: "deployments/docker/testing/ssh-kms-test/ssh-keystore/" \ No newline at end of file diff --git a/docs/content/en/docs/installation-configuration/_index.md b/docs/content/en/docs/installation-configuration/_index.md index fc789b1b..59cd7b1a 100644 --- a/docs/content/en/docs/installation-configuration/_index.md +++ b/docs/content/en/docs/installation-configuration/_index.md @@ -18,7 +18,7 @@ After completing the [Getting Started](/docs/getting-started/_index.md) setup fo | ENABLE_TLS | `false` | Enable TLS for secure communication. Default is `false`. | | CERT_FILE | `"/certs/server.crt"` | Path to the TLS certificate file. Default is `"/certs/server.crt"`. | | CERT_KEY_FILE | `"/certs/server.key"` | Path to the TLS certificate key file. Default is `"/certs/server.key"`. | -| MAX_EXECUTIONS | `1000` | The number of historical executions saved, including the current one. Default is `1000`. | +| MAX_EXECUTIONS | `1000` | The number of historical executions saved, including the current one. Default is `10`. | | SOARCA_ALLOWED_ORIGINS | `*` | Set allowed origins for cross-origin requests. Default is `*`. | | GIN_MODE | `release` | Set the GIN mode. Default is `release`. | | DATABASE | `false` | Set if you want to run with an external database. Default is `false`. | @@ -27,7 +27,7 @@ After completing the [Getting Started](/docs/getting-started/_index.md) setup fo | DB_USERNAME | `root` | Set the MongoDB database user when using Docker. Default is `root`. | | DB_PASSWORD | `rootpassword` | Set the MongoDB database user password when using Docker. **Change this in production!** Default is `rootpassword`. | | PLAYBOOK_API_LOG_LEVEL | `trace` | Set the log level for the playbook API. Default is `trace`. | -| MAX_REPORTERS | `5` | Set the maximum number of downstream reporters. Default is `5`. | +| MAX_REPORTERS | `10` | Set the maximum number of downstream reporters. Default is `10`. | | LOG_GLOBAL_LEVEL | `info` | One of the specified log levels. Default is `info`. | | LOG_MODE | `development` | Set the logging mode. If `production`, `LOG_GLOBAL_LEVEL` is used for all modules. Default is `development`. | | LOG_FILE_PATH | `""` | Path to the logfile for all logging. Default is `""` (empty string). | @@ -55,10 +55,27 @@ After completing the [Getting Started](/docs/getting-started/_index.md) setup fo ### Authentication {{% alert title="Note" color="primary" %}} -More information on setting up authentication can be found [here](/docs/installation-configuration/authentication.md). +More information on setting up authentication can be found [here](/docs/installation-configuration/authentication/). {{% /alert %}} -| Variable | Content | Description | -|------------------------|---------------------------------------------|---------------------------------------------------------------------------------------------| +| Variable | Content | Description | +|---|---|---| +| AUTH_ENABLED | `false` | Require a valid credential on every API request. Default is `false`. | +| OIDC_ISSUER | `https://authentik.example.com/application/o/soarca/` | Issuer URL of the OIDC provider (authentik). | +| OIDC_CLIENT_ID | `SOME_CLIENT_ID` | Client ID of the OIDC provider; the expected token audience. | +| OIDC_SKIP_TLS_VERIFY | `false` | Skip certificate checks towards the OIDC provider. Default is `false`. | +| AUTH_GROUP | `soarca_admin` | OIDC group whose members get every permission. Default is `soarca_admin`. | +| AUTH_ENTRA_TENANT_IDS | `` | Comma-separated Entra ID tenants whose tokens SOARCA accepts. | +| AUTH_ENTRA_API_CLIENT_ID | `` | Client ID of the SOARCA API app registration in Entra ID. | +| AUTH_ENTRA_API_APP_ID_URI | `api://soarca` | Application ID URI of that registration. Optional. | +| AUTH_ENTRA_ALLOWED_CLIENT_IDS | `,` | Applications allowed to call SOARCA. Empty allows all in the tenant. | +| AUTH_ENTRA_REQUIRED_USER_SCOPE | `access_as_user` | Delegated scope user tokens must carry. Default is `access_as_user`. | +| AUTH_ENTRA_ACCEPT_V1 | `false` | Also accept Entra ID v1 tokens. Default is `false`. | +| AUTH_ENTRA_AUTHORITY | `https://login.microsoftonline.com` | Entra ID login host; change only for national clouds. | +| AUTH_API_KEYS_FILE | `/etc/soarca/api-keys.json` | JSON file with hashed API keys. Empty disables API keys. | + +`OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URL` and `COOKIE_SECRET_KEY` are used by [SOARCA-GUI](/docs/soarca-gui/) for signing users in; SOARCA itself doesn't read them. + +------------------------|---------------------------------------------|---------------------------------------------------------------------------------------------| | AUTH_ENABLED | `false` | Enable authentication. Default is `false`. | | OIDC_ISSUER | `https://localhost:9443/application/u/test/` | The OIDC issuer URL. | | OIDC_CLIENT_ID | `SOME_CLIENT_ID` | Set the OIDC client ID. | diff --git a/docs/content/en/docs/installation-configuration/authentication.md b/docs/content/en/docs/installation-configuration/authentication.md deleted file mode 100644 index 81dbac00..00000000 --- a/docs/content/en/docs/installation-configuration/authentication.md +++ /dev/null @@ -1,158 +0,0 @@ - ---- - -title: Setup RBAC for SOARCA -description: > - Setup OIDC based Role-Based Access Control (RBAC) for SOARCA -categories: [extensions, architecture] -tags: [security] -weight: 2 - ---- -Authentication is disabled by default in SOARCA. This means that when SOARCA is launched with its default configuration and exposed to a network, anyone can interact with it. Since SOARCA requires significant capabilities and access to reconfigure systems, exposing it without authentication poses a security risk. This section outlines how to set up authentication and authorization for SOARCA. - -SOARCA leverages our internally developed [gauth library](https://github.com/COSSAS/gauth) as its underlying authentication framework. This library provides convenient Role-Based Access Control (RBAC) middleware, which manages authentication for various endpoints, such as the Playbook API. Based on OpenID Connect (OIDC), the library supports integration with multiple authentication providers. - -Currently, for the used [gauth library](https://github.com/COSSAS/gauth) the [Authentik](https://goauthentik.io/) authentication provider, an open-source solution that supports a wide range of authentication methods is supported and tested. As such other OIDC-based providers might not be compatible. - -## Supported OIDC-Based Auth Providers - -- [Authentik](https://goauthentik.io/) - -## Enabling RBAC - -Enabling RBAC can be done by setting the `AUTH_ENABLED: true`. - -{{< tabpane langEqualsHeader=false >}} -{{< tab header="`.env`" lang="txt" >}} -AUTH_ENABLED: true -AUTH_GROUP: "soarca_admin" -OIDC_ISSUER: "https://authentikuri:9443/application/u/test/" -OIDC_CLIENT_SECRET: "SOME_CLIENT_SECRET" -OIDC_CLIENT_ID: "SOME_CLIENT_ID" -OIDC_SKIP_TLS_VERIFY: true -{{< /tab >}} -{{< /tabpane >}} - -## Setting up Authentik with SOARCA - -Next, we need to obtain variables such as `OIDC_ISSUER` etc. This section will describe how we can setup [Authentik](https://goauthentik.io/). - -### Spinning up Authentik - -Instruction and docker-compose on how to bundle SOARCA with Authentik will come! - -### Making an authentication provider - -In Authentik first setup a new provider. This can be done under `Applications` -> `Providers` ->`Create`. For the provider type select the `OAuth2/OpenID provider` from the various options. An example configuration is given below: - -{{% alert title="Note" color="primary" %}} -We use an token lifetime of **8 hours**, since the [SOARCA-GUI](https://github.com/COSSAS/SOARCA-GUI) uses this token for client validation. As we do not want the user to login every so minute. It is advised to set this to 8 hours. -{{% /alert %}} - -Next, we need to set in the advanced protocol settings the token expiration lifetime needs to be changed to **8 hours**. - -![core](/SOARCA/images/installation_configuration/authentik_setup/setup-provider.png) - -![core](/SOARCA/images/installation_configuration/authentik_setup/change-lifetime.png) - -Endpoints for the auth provider can also be found here: - -![core](/SOARCA/images/installation_configuration/authentik_setup/endpoints.png) - -### Making an application - -Next, we can create a new application as shown in the picture below. A new application can be added under `Applications` --> `Create` Add the earlier made provider to this application. - -![core](/SOARCA/images/installation_configuration/authentik_setup/setting-application.png) - -### Setting the authentication provider - -Next, under `providers` -> `soarca-auth-provider` -> `edit` we can find the following overview: - -![core](/SOARCA/images/installation_configuration/authentik_setup/view-provider.png) - -Here we can find the: - -- Client ID: `some random stuff` -- Client Secret: `some other random stuff` -- Redirect Url: Optional, should be set when using for the SOARCA-GUI explained [here] - -![core](/SOARCA/images/installation_configuration/authentik_setup/editing-provider.png) - -We only use the Authentik integration for token validation on the SOARCA side. As such, only the `OIDC_CLIENT_ID` is required here. For the SOARCA-GUI, we would need the `OIDC_CLIENT_SECRET`. - -{{% alert title="Warning" color="warning" %}} -It is not advised to run Authentik like this! Please setup TLS certificates in a real environment and set the `OIDC_SKIP_TLS_VERIFY` to `false`. -{{% /alert %}} - -Set these variables in the environment variables settings, for example: -{{< tabpane langEqualsHeader=false >}} -{{< tab header="`.env`" lang="txt" >}} -AUTH_ENABLED: true -AUTH_GROUP: "soarca_admin" -OIDC_ISSUER: "https://authentikuri/application/o/does-providing-for-soarca/" -OIDC_CLIENT_ID: "WxUcBMGZdI7c0e5oYp6mYdEd64acpXSuWKh8zBH5" -OIDC_SKIP_TLS_VERIFY: true -{{< /tab >}} - -{{< tab header="`bash`" lang="bash" >}} -export AUTH_ENABLED=true -export AUTH_GROUP="soarca_admin" -export OIDC_ISSUER="https://authentikuri:9443/application/o/does-providing-for-soarca/" -export OIDC_CLIENT_ID="WxUcBMGZdI7c0e5oYp6mYdEd64acpXSuWKh8zBH5" -export OIDC_SKIP_TLS_VERIFY=true -{{< /tab >}} -{{< /tabpane >}} - -### Adding SOARCA user group and users - -{{% alert title="Note" color="primary" %}} -Again, for the current version of the implementation we only support one group to differentiate between access to the different endpoint. We plan for a later version of SOARCA to have different groups/permissions for a given API endpoint. -{{% /alert %}} - -For the current version of SOARCA and the gauth library the access to the API for a given user is dependent on the required set `AUTH_GROUP`. Users are required to be in the same group as the group that has been set through this variable. Currently, there is not yet control over which group can access a specific API or route grooup. This feature is scheduled for a future version of SOARCA. In the example below, the `AUTH_GROUP: soarca_admin` is set. - -Next, we require to setup a group in Authentik that is called `soarca_admin` as explained earlier. The to be obtained tokens from Authentik needs to have this group information as this will be checked by the middleware. A group can be created under `Directory` -> `Groups` -> `New`. - -Under `users` normal as as service accounts can be created. We advise for machine-to-machine implementation service accounts, and for normal users (used for example for SOARCA-GUI logins) normal accounts. Now we can make an users and add to the `soarca_admin` group. Make use under the application that this group is added to the application provider that we have setup earlier, otherwise the grants of token might fail. - -![core](/SOARCA/images/installation_configuration/authentik_setup/groups.png) - -![core](/SOARCA/images/installation_configuration/authentik_setup/add-user.png) - -![core](/SOARCA/images/installation_configuration/authentik_setup/add-groups.png) - -### Authentication with Bearer - -Now that authentication and authorization is enabled, every request requires to have a set `Authorization: Bearer ` header. - -``` -POST /trigger/playbook/ HTTP/1.1 -Host: localhost:8080 -Content-Type: application/json -Authorization: Bearer -Content-Length: 2345 - -{ - "type": "playbook", - "spec_version": "cacao-2.0", - "id": "playbook--300270f9-0e64-42c8-93cc-0927edbe3ae7", - "name": "Example ssh", - ... -} -``` - -The [gauth library](https://github.com/COSSAS/gauth) will validate this bearer token against the setup Authentik provider and grant the user or application access. Replace the token with a working bearer token. - -{{% alert title="Tip" %}} -For obtaining an access (bearer) token for Authentik, we have provided an example [here](https://github.com/COSSAS/gauth/examples/m2m) -{{% /alert %}} - -An example curl command is provided below: - -{{< tabpane langEqualsHeader=false >}} -{{< tab header="`bash`" lang="bash" >}} -curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer " -d @./example/ssh-playbook.json localhost:8080/trigger/playbook -{{< /tab >}} -{{< /tabpane >}} diff --git a/docs/content/en/docs/installation-configuration/authentication/_index.md b/docs/content/en/docs/installation-configuration/authentication/_index.md new file mode 100644 index 00000000..44bf1277 --- /dev/null +++ b/docs/content/en/docs/installation-configuration/authentication/_index.md @@ -0,0 +1,146 @@ +--- +title: Authentication and authorization +description: > + Secure the SOARCA API with Microsoft Entra ID, authentik or another OIDC provider, and static API keys +categories: [extensions, architecture] +tags: [security] +weight: 2 +--- + +Authentication is disabled by default in SOARCA. With the default configuration, anyone who can reach SOARCA over the network can submit and execute playbooks. Because playbooks can reconfigure the systems SOARCA is connected to, enable authentication for every deployment that is reachable by more than a single trusted machine. + +With authentication enabled, every API request needs an `Authorization: Bearer ` header. SOARCA accepts three kinds of credentials, side by side: + +| Credential | Issued by | Typical caller | +|---|---|---| +| Entra ID access token | Microsoft Entra ID | Analysts with a Microsoft account, Azure workloads, integrations registered in Entra | +| OIDC access token | [authentik](https://goauthentik.io/) or another OpenID Connect provider | Analysts and service accounts managed in authentik | +| API key | SOARCA itself | Systems that cannot request an OAuth token, such as webhook sources | + +You can enable any combination. SOARCA looks at each credential and hands it to the matching provider: a JWT by its issuer (`iss` claim), an API key by its `soarca_` prefix. Tokens from an issuer SOARCA isn't configured for are rejected. + +## Permissions + +SOARCA grants access through four permissions. Each identity provider maps its own roles or groups onto them; see the provider pages below for the exact names. + +| Permission | Allows | +|---|---| +| `playbook:read` | Reading playbooks, execution reports and status | +| `playbook:write` | Creating, updating and deleting playbooks | +| `playbook:execute` | Triggering playbooks and answering manual steps | +| `soarca:admin` | Everything, including key management | + +Every role that grants `playbook:write` or `playbook:execute` also grants `playbook:read`. + +| Route | Required permission | +|---|---| +| `GET /playbook/`, `GET /playbook/meta/`, `GET /playbook/:id` | `playbook:read` | +| `POST /playbook/`, `PUT /playbook/:id`, `DELETE /playbook/:id` | `playbook:write` | +| `GET /reporter/`, `GET /reporter/:id`, `GET /status/`, `GET /status/ping` | `playbook:read` | +| `POST /trigger/playbook`, `POST /trigger/playbook/:id` | `playbook:execute` | +| `GET /manual/`, `GET /manual/:exec_id/:step_id`, `POST /manual/continue` | `playbook:execute` | +| `/keymanagement/*` | `soarca:admin` | + +A request without a valid credential gets `401 Unauthorized`. A valid credential that lacks the permission gets `403 Forbidden`. Both use SOARCA's usual error format: + +```json +{ + "status": "403", + "message": "forbidden: requires playbook:execute", + "original-call": "POST /trigger/playbook", + "downstream-call": "" +} +``` + +## Setting up a provider + +- [Microsoft Entra ID](entra/) +- [authentik](authentik/) (and other OpenID Connect providers) +- [API keys](api-keys/) + +## Environment variables + +Authentication is turned on with `AUTH_ENABLED: true`. SOARCA then refuses to start unless at least one provider is fully configured. + +### General + +| Variable | Default | Description | +|---|---|---| +| `AUTH_ENABLED` | `false` | Require a valid credential on every API request. | + +### OpenID Connect (authentik) + +| Variable | Default | Description | +|---|---|---| +| `OIDC_ISSUER` | | Issuer URL, exactly as it appears in the tokens' `iss` claim, including any trailing slash. | +| `OIDC_CLIENT_ID` | | Client ID of the SOARCA provider. Tokens must carry it as their audience. | +| `OIDC_SKIP_TLS_VERIFY` | `false` | Skip certificate checks towards the provider. Only for test setups with self-signed certificates. | +| `AUTH_GROUP` | `soarca_admin` | Members of this group get every permission. | + +`OIDC_ISSUER` and `OIDC_CLIENT_ID` must be set together. + +### Microsoft Entra ID + +| Variable | Default | Description | +|---|---|---| +| `AUTH_ENTRA_TENANT_IDS` | | Comma-separated tenant IDs whose tokens SOARCA accepts. | +| `AUTH_ENTRA_API_CLIENT_ID` | | Application (client) ID of the SOARCA API app registration. | +| `AUTH_ENTRA_API_APP_ID_URI` | | Application ID URI of that registration, e.g. `api://soarca`. Optional. | +| `AUTH_ENTRA_ALLOWED_CLIENT_IDS` | | Comma-separated client IDs of the applications allowed to call SOARCA. Empty allows every application in an allowed tenant. | +| `AUTH_ENTRA_REQUIRED_USER_SCOPE` | `access_as_user` | Delegated scope that user tokens must carry. | +| `AUTH_ENTRA_ACCEPT_V1` | `false` | Also accept v1 tokens (issuer `https://sts.windows.net//`). | +| `AUTH_ENTRA_AUTHORITY` | `https://login.microsoftonline.com` | Login host. Change it only for national clouds, e.g. `https://login.microsoftonline.us`. | + +`AUTH_ENTRA_TENANT_IDS` and `AUTH_ENTRA_API_CLIENT_ID` must be set together. + +### API keys + +| Variable | Default | Description | +|---|---|---| +| `AUTH_API_KEYS_FILE` | | Path to a JSON file with hashed API keys. Empty disables API keys. | + +### Example + +{{< tabpane langEqualsHeader=false >}} +{{< tab header="`.env`" lang="txt" >}} +AUTH_ENABLED: true + +# authentik +OIDC_ISSUER: "https://authentik.example.com/application/o/soarca/" +OIDC_CLIENT_ID: "soarca" +AUTH_GROUP: "soarca_admin" + +# Entra ID +AUTH_ENTRA_TENANT_IDS: "11111111-1111-1111-1111-111111111111" +AUTH_ENTRA_API_CLIENT_ID: "aaaaaaaa-0000-0000-0000-000000000001" +AUTH_ENTRA_API_APP_ID_URI: "api://soarca" + +# API keys +AUTH_API_KEYS_FILE: "/etc/soarca/api-keys.json" +{{< /tab >}} +{{< /tabpane >}} + +## Checking a token + +Access tokens are signed, not encrypted, so you can read their claims. This is the quickest way to find out why SOARCA rejects one: + +```bash +echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq +``` + +Compare `iss` with the issuer SOARCA is configured for, `aud` with the client ID, and `roles` (Entra ID) or `groups` (authentik) with the role tables on the provider pages. + +Then call SOARCA with it: + +```bash +curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/playbook/ +``` + +## Common problems + +| Response | Cause | +|---|---| +| `401` for every request | The token's issuer isn't configured, the token expired, or it was issued for another audience. Decode the token and compare `iss` and `aud`. | +| `401` with an Entra token whose `aud` is `https://graph.microsoft.com` | The caller requested a Microsoft Graph token. Request a token for the SOARCA API instead; see [Entra ID](entra/). | +| `403 forbidden: requires …` | The credential is valid, but none of its roles or groups grants that permission. | +| SOARCA doesn't start: `AUTH_ENABLED=true requires …` | Authentication is enabled without a complete provider configuration. | diff --git a/docs/content/en/docs/installation-configuration/authentication/api-keys.md b/docs/content/en/docs/installation-configuration/authentication/api-keys.md new file mode 100644 index 00000000..35cd994e --- /dev/null +++ b/docs/content/en/docs/installation-configuration/authentication/api-keys.md @@ -0,0 +1,67 @@ +--- +title: API keys +description: > + Static keys for systems that cannot request an OAuth token +categories: [extensions, architecture] +tags: [security] +weight: 3 +--- + +Some systems can only send a fixed header, for example a webhook source that triggers a playbook. For those, SOARCA accepts static API keys. Prefer [Entra ID](../entra/) or [authentik](../authentik/) wherever the caller can request a token: tokens expire within hours, while a leaked key stays valid until it's removed or expires. + +SOARCA never stores the keys themselves, only their SHA-256 hash. Every key starts with `soarca_`, which lets SOARCA recognise it and lets secret scanners find leaked ones. + +## Create a key + +```bash +KEY="soarca_$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')" +HASH=$(printf '%s' "$KEY" | sha256sum | cut -d' ' -f1) +echo "Key for the caller: $KEY" +echo "Hash for SOARCA: $HASH" +``` + +Give the key to the caller once, through a secure channel, and don't keep it anywhere else. On macOS, use `shasum -a 256` instead of `sha256sum`. + +## Register it with SOARCA + +Add an entry to the API keys file, a JSON array: + +```json +[ + { + "name": "webhook", + "sha256": "", + "permissions": ["playbook:execute"], + "expires": "2027-01-31T00:00:00Z" + } +] +``` + +| Field | Description | +|---|---| +| `name` | Identifies the caller in logs. Use a different name for every key. | +| `sha256` | The hex SHA-256 hash of the key. | +| `permissions` | The [permissions](../#permissions) the key grants. A key grants exactly what's listed; `playbook:execute` doesn't include `playbook:read` here. | +| `expires` | When the key stops working, in RFC 3339 format. Required. | + +Point SOARCA to the file: + +{{< tabpane langEqualsHeader=false >}} +{{< tab header="`.env`" lang="txt" >}} +AUTH_ENABLED: true +AUTH_API_KEYS_FILE: "/etc/soarca/api-keys.json" +{{< /tab >}} +{{< /tabpane >}} + +SOARCA reads the file at startup and refuses to start if an entry has no name, an invalid hash or no expiry. Restart SOARCA after changing the file. Keep the file readable only by the user that runs SOARCA. + +## Use it + +```bash +curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d @playbook.json http://localhost:8080/trigger/playbook +``` + +## Revoke or rotate a key + +Remove the entry from the file, or add a new entry first and remove the old one once the caller has switched, then restart SOARCA. diff --git a/docs/content/en/docs/installation-configuration/authentication/authentik.md b/docs/content/en/docs/installation-configuration/authentication/authentik.md new file mode 100644 index 00000000..0ea749fe --- /dev/null +++ b/docs/content/en/docs/installation-configuration/authentication/authentik.md @@ -0,0 +1,142 @@ +--- +title: authentik and other OIDC providers +description: > + Let users and service accounts from authentik, or another OpenID Connect provider, call SOARCA +categories: [extensions, architecture] +tags: [security] +weight: 2 +--- + +SOARCA accepts access tokens from one OpenID Connect provider, found through its discovery document. This page uses [authentik](https://goauthentik.io/), which SOARCA is tested with. [Other providers](#other-oidc-providers) work if their tokens meet the same requirements. + +Permissions come from the groups a user or service account belongs to: + +| Group | SOARCA permissions | +|---|---| +| `soarca_read` | `playbook:read` | +| `soarca_write` | `playbook:read`, `playbook:write` | +| `soarca_execute` | `playbook:read`, `playbook:execute` | +| `soarca_admin` (or the value of `AUTH_GROUP`) | all permissions | + +Group names are case-sensitive. Existing setups where every user is in `soarca_admin` keep working unchanged. + +## 1. Create a provider + +In authentik, go to **Applications** → **Providers** → **Create** and select **OAuth2/OpenID Provider**. + +![core](/SOARCA/images/installation_configuration/authentik_setup/setup-provider.png) + +- **Name**: e.g. `soarca`. +- **Authorization flow**: the implicit or explicit consent flow. +- **Client type**: **Confidential**. +- **Signing key**: select a certificate, for example *authentik Self-signed Certificate*. + +{{% alert title="Warning" color="warning" %}} +Always select a signing key. Without one, authentik signs tokens with the client secret (HS256), and SOARCA rejects them because it verifies signatures against the provider's published keys. +{{% /alert %}} + +Under **Advanced protocol settings**: + +- **Scopes**: keep `openid`, `email` and `profile`. The `profile` scope adds the `groups` claim that SOARCA reads. +- **Access token validity**: SOARCA-GUI uses the access token for the whole session, so set it to the session length you want, for example **8 hours**. Integrations that request their own tokens can live with much shorter lifetimes. + +![core](/SOARCA/images/installation_configuration/authentik_setup/change-lifetime.png) + +To let service accounts request tokens, enable the **Client credentials** grant type in the provider's settings. + +After saving, open the provider. Its overview lists the endpoints. The **OpenID Configuration Issuer** is the value for `OIDC_ISSUER`, and the **Client ID** is the value for `OIDC_CLIENT_ID`. + +![core](/SOARCA/images/installation_configuration/authentik_setup/endpoints.png) + +## 2. Create an application + +Go to **Applications** → **Applications** → **Create**, give it a name and slug (e.g. `soarca`), and select the provider from step 1. + +![core](/SOARCA/images/installation_configuration/authentik_setup/setting-application.png) + +The slug is part of the issuer URL: `https:///application/o//`. + +## 3. Create groups and accounts + +Under **Directory** → **Groups**, create the groups from the table above that you need. + +![core](/SOARCA/images/installation_configuration/authentik_setup/groups.png) + +Under **Directory** → **Users**, add people as normal users and put them in the right groups. + +![core](/SOARCA/images/installation_configuration/authentik_setup/add-user.png) + +![core](/SOARCA/images/installation_configuration/authentik_setup/add-groups.png) + +For machine-to-machine access, create a **service account** for each integration and add it to the group it needs, e.g. `soarca_execute`. Then create an **app password** for it under **Directory** → **Tokens and App passwords**. The integration uses the account's username and this app password to request tokens. + +If the application has policy bindings that restrict access, bind the SOARCA groups to it as well, otherwise token requests fail. + +## 4. Configure SOARCA + +{{< tabpane langEqualsHeader=false >}} +{{< tab header="`.env`" lang="txt" >}} +AUTH_ENABLED: true +OIDC_ISSUER: "https://authentik.example.com/application/o/soarca/" +OIDC_CLIENT_ID: "" +OIDC_SKIP_TLS_VERIFY: false +AUTH_GROUP: "soarca_admin" +{{< /tab >}} +{{< tab header="`bash`" lang="bash" >}} +export AUTH_ENABLED=true +export OIDC_ISSUER="https://authentik.example.com/application/o/soarca/" +export OIDC_CLIENT_ID="" +export OIDC_SKIP_TLS_VERIFY=false +export AUTH_GROUP="soarca_admin" +{{< /tab >}} +{{< /tabpane >}} + +`OIDC_ISSUER` must match the `iss` claim of the tokens exactly, including the scheme, the port and the trailing slash. authentik builds the issuer from the host name it's called on, so SOARCA and the clients requesting tokens must use the same URL for authentik. + +SOARCA contacts authentik on the first request that needs it, not at startup, so it can start before authentik is reachable. If authentik is down, requests fail with `401` until it's back. + +{{% alert title="Warning" color="warning" %}} +`OIDC_SKIP_TLS_VERIFY: true` disables certificate checks towards authentik. Use it only in test setups with self-signed certificates. +{{% /alert %}} + +## 5. Get a token + +Users get their token by signing in to [SOARCA-GUI](/docs/soarca-gui/). For a service account, use the client credentials grant with the account's username and app password: + +```bash +TOKEN=$(curl -s https://authentik.example.com/application/o/token/ \ + -d grant_type=client_credentials \ + -d client_id= \ + -d username= \ + --data-urlencode password= \ + -d "scope=openid profile email" | jq -r .access_token) + +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d @playbook.json http://localhost:8080/trigger/playbook +``` + +Without `profile` in the scope, the token carries no groups and SOARCA answers `403`. + +## Other OIDC providers + +SOARCA works with any provider whose access tokens: + +- are JWTs signed with a key published in the provider's JWKS (the discovery document's `jwks_uri`), not with the client secret; +- carry an `iss` claim equal to `OIDC_ISSUER`, with a discovery document at `/.well-known/openid-configuration`; +- carry the client ID from `OIDC_CLIENT_ID` in their `aud` claim; +- list the caller's group names in a `groups` claim. + +In Keycloak, for example, add a **Group Membership** mapper named `groups` (with **Full group path** off) and an **Audience** mapper for the SOARCA client to the client's dedicated scope. + +## Troubleshooting + +Decode the token as described in [Checking a token](../#checking-a-token). + +| Symptom | Cause | Fix | +|---|---|---| +| `401`, and `iss` differs from `OIDC_ISSUER` | The token was requested via another host name, scheme or port | Request tokens via the same URL that `OIDC_ISSUER` uses. Check the trailing slash. | +| `401`, and the token header says `"alg": "HS256"` | No signing key on the provider | Select a signing key on the provider and request a new token. | +| `401`, and `aud` differs from `OIDC_CLIENT_ID` | The token was issued for another provider | Use the client ID of the SOARCA provider. | +| `403`, and the token has no `groups` claim | `profile` wasn't requested, or the scope mapping is missing | Request `openid profile email`, and check the provider's scopes. | +| `403` with `groups` present | None of the groups grants the permission | Add the account to a group from the table above, or check `AUTH_GROUP`. | +| `invalid_grant` from authentik | Wrong username or app password, or client credentials aren't enabled | Check the app password and the provider's grant types. | diff --git a/docs/content/en/docs/installation-configuration/authentication/entra.md b/docs/content/en/docs/installation-configuration/authentication/entra.md new file mode 100644 index 00000000..052f2f76 --- /dev/null +++ b/docs/content/en/docs/installation-configuration/authentication/entra.md @@ -0,0 +1,175 @@ +--- +title: Microsoft Entra ID +description: > + Let users and applications from Microsoft Entra ID call SOARCA +categories: [extensions, architecture] +tags: [security] +weight: 1 +--- + +SOARCA accepts access tokens that Entra ID issues for a dedicated **SOARCA API** app registration. Permissions come from app roles on that registration, so you decide in Entra ID who may read, write or execute playbooks. + +You need an account that can create app registrations and grant admin consent, for example *Application Administrator*. Note your **tenant ID** from the Entra admin center overview page. + +## How it fits together + +``` +SOARCA API (app registration) + Application ID URI api://soarca + Scope access_as_user + App roles Playbook.Read, Playbook.Write, Playbook.Execute, SOARCA.Admin + ^ ^ + | delegated scope | application permission + | + the user's assigned app roles | (app role assigned to the application) + | | +A client that signs users in An integration or managed identity +(authorization code flow) (client credentials flow) +``` +```plantuml +@startuml +skinparam componentStyle rectangle + +component "**SOARCA API** (app registration)\n\nApplication ID URI: api://soarca\nScope: access_as_user\nApp roles: Playbook.Read, Playbook.Write,\nPlaybook.Execute, SOARCA.Admin" as api + +component "**A client that signs users in**\n(authorization code flow)" as client +component "**An integration or managed identity**\n(client credentials flow)" as integration + +client -> api : delegated scope\n+ the user's assigned app roles +api <-- integration : application permission\n(app role assigned to the application) +@enduml +``` + + +One registration represents SOARCA. Everything that calls SOARCA gets its own registration, or is a managed identity. Users and groups receive app roles through assignment; applications receive them as application permissions. Either way the roles end up in the token's `roles` claim, which SOARCA maps to permissions: + +| App role value | SOARCA permissions | +|---|---| +| `Playbook.Read` | `playbook:read` | +| `Playbook.Write` | `playbook:read`, `playbook:write` | +| `Playbook.Execute` | `playbook:read`, `playbook:execute` | +| `SOARCA.Admin` | all permissions | + +The values are case-sensitive and must match exactly. + +## 1. Register the SOARCA API + +1. In the Entra admin center, go to **Identity** → **Applications** → **App registrations** → **New registration**. Name it `SOARCA API`, choose **Accounts in this organizational directory only**, leave the redirect URI empty and register. +2. Copy the **Application (client) ID**. This is `AUTH_ENTRA_API_CLIENT_ID`. +3. **Expose an API** → **Application ID URI** → **Add**. Use `api://soarca`, or accept the suggested `api://` if your tenant doesn't allow custom URIs. This is `AUTH_ENTRA_API_APP_ID_URI`. +4. **Expose an API** → **Add a scope**: name `access_as_user`, who can consent **Admins only**, state **Enabled**. +5. **App roles** → **Create app role**, four times, each with allowed member types **Both (Users/Groups + Applications)** and the values from the table above. +6. **Manifest**: set `"requestedAccessTokenVersion": 2` in the `api` section and save. Without it, Entra ID issues v1 tokens, which SOARCA rejects unless `AUTH_ENTRA_ACCEPT_V1` is `true`. +7. **Token configuration** → **Add optional claim** → token type **Access** → `idtyp`. This tells SOARCA reliably whether a token belongs to a user or an application. +8. **Enterprise applications** → **SOARCA API** → **Properties**: set **Assignment required?** to **Yes**. Now only users, groups and applications that hold an app role can get a token for SOARCA. + +{{< tabpane langEqualsHeader=false >}} +{{< tab header="Azure CLI" lang="bash" >}} +API_APP_ID=$(az ad app create --display-name "SOARCA API" \ + --sign-in-audience AzureADMyOrg --query appId -o tsv) +API_OBJECT_ID=$(az ad app show --id $API_APP_ID --query id -o tsv) +az ad app update --id $API_APP_ID --identifier-uris api://soarca + +cat > roles.json <}} +{{< /tabpane >}} + +## 2. Give users access + +**Enterprise applications** → **SOARCA API** → **Users and groups** → **Add user/group**. Pick a user or group and one of the app roles. + +{{% alert title="Note" color="primary" %}} +Assigning *groups* to app roles requires Entra ID P1 or P2. Only direct members of an assigned group receive the role; nested groups are not expanded. +{{% /alert %}} + +A user token for SOARCA is requested with the scope `api://soarca/access_as_user` by any client the user signs in to. That client needs the delegated permission **SOARCA API** → `access_as_user`, with admin consent. + +To test as a user without building a client, pre-authorize the Azure CLI: **Expose an API** → **Add a client application** → client ID `04b07795-8ddb-461a-bbee-02f9e1bf7b46` with the `access_as_user` scope. Then: + +```bash +az login --tenant +TOKEN=$(az account get-access-token --scope api://soarca/access_as_user --query accessToken -o tsv) +curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/playbook/ +``` + +## 3. Give applications access + +Create one registration per integration, so you can revoke one without affecting the others and SOARCA's logs show which integration did what. + +1. **App registrations** → **New registration**, e.g. `SOARCA client - Terminal`, single tenant, no redirect URI. +2. **Certificates & secrets**: upload a certificate (preferred) or create a client secret. For a secret, copy the **Value** column, not the Secret ID. +3. **API permissions** → **Add a permission** → **My APIs** → **SOARCA API** → **Application permissions**. Select only the roles the integration needs, e.g. `Playbook.Execute`. +4. **Grant admin consent**. The status column must show a green check. + +The integration requests a token with the client credentials flow. The scope is always `api://soarca/.default`: + +```bash +TOKEN=$(curl -s https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token \ + -d grant_type=client_credentials \ + -d client_id=$CLIENT_ID \ + --data-urlencode client_secret=$CLIENT_SECRET \ + -d scope=api://soarca/.default | jq -r .access_token) + +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d @playbook.json http://localhost:8080/trigger/playbook +``` + +{{% alert title="Tip" color="primary" %}} +Workloads running in Azure can use a **managed identity** instead of a secret. Assign the app role to the managed identity's service principal (`az rest --method POST --uri "https://graph.microsoft.com/v1.0/servicePrincipals//appRoleAssignedTo"` with its `principalId`) and request a token for `api://soarca/.default` inside the workload. +{{% /alert %}} + +To accept only specific applications, list their client IDs in `AUTH_ENTRA_ALLOWED_CLIENT_IDS`. Include the client that signs users in, or user tokens are rejected too. + +## 4. Configure SOARCA + +{{< tabpane langEqualsHeader=false >}} +{{< tab header="`.env`" lang="txt" >}} +AUTH_ENABLED: true +AUTH_ENTRA_TENANT_IDS: "" +AUTH_ENTRA_API_CLIENT_ID: "" +AUTH_ENTRA_API_APP_ID_URI: "api://soarca" +{{< /tab >}} +{{< /tabpane >}} + +SOARCA downloads the tenant's signing keys from Entra ID on the first request and caches them, so it needs outbound HTTPS access to `login.microsoftonline.com`. + +## Troubleshooting + +Decode the token as described in [Checking a token](../#checking-a-token). + +| Symptom | Cause | Fix | +|---|---|---| +| `iss` is `https://sts.windows.net/…`, `ver` is `1.0` | v1 tokens | Set `requestedAccessTokenVersion` to `2` on the **SOARCA API** registration and request a new token. | +| `aud` is `00000003-0000-0000-c000-000000000000` or `https://graph.microsoft.com` | A Microsoft Graph token was requested | Use the scope `api://soarca/.default` (applications) or `api://soarca/access_as_user` (users). | +| No `roles` claim, SOARCA answers `403` | No app role assigned, or consent missing | Check **API permissions** for a green check, and **Users and groups** on the enterprise application. | +| `AADSTS501051` when requesting a token | Assignment is required and the caller holds no app role | Assign an app role. | +| `AADSTS500011` | The Application ID URI in the scope is wrong | Use the exact URI from **Expose an API**. | +| `AADSTS7000215` | Wrong client secret | Use the secret's **Value**, not its ID, and check that it hasn't expired. | +| SOARCA answers `401` for user tokens only | The token lacks the `access_as_user` scope, or the signing-in client isn't in `AUTH_ENTRA_ALLOWED_CLIENT_IDS` | Request `api://soarca/access_as_user`; check the allowlist. | +| A role change has no effect | Entra ID caches tokens for 60–90 minutes, managed identities up to 24 hours | Request a new token. | diff --git a/docs/content/en/docs/soarca-gui/_index.md b/docs/content/en/docs/soarca-gui/_index.md index cf7feff4..8ab461d4 100644 --- a/docs/content/en/docs/soarca-gui/_index.md +++ b/docs/content/en/docs/soarca-gui/_index.md @@ -18,7 +18,7 @@ SOARCA can now work with a front-end interface called the SOARCA-GUI (written in Our long-term vision for the SOARCA-GUI includes enabling users to configure SOARCA directly, test integrations using tools like the SOARCA Fin library, and manage these tasks without requiring terminal commands or interventions. Additionally, we plan to introduce functionality for viewing and managing playbooks in a future version of the interface. -The SOARCA-GUI features OIDC-based login for authentication and authorization. Similar to SOARCA, the SOARCA-GUI uses the [gauth](https://github.com/COSSAS/gauth) library as authentication & authorization middleware. This middleware is known to work with [Authentik](https://goauthentik.io/). For more information on setting up authentication for SOARCA, please refer to the documentation [here](/docs/installation-configuration/authentication.md). Authentication only works when enabling OIDC, as such if you want to have authentication you are required to setup Authentik or a different OIDC-provider. Note, that other OIDC-providers have not been tested yet. +The SOARCA-GUI features OIDC-based login for authentication and authorization. This middleware is known to work with [Authentik](https://goauthentik.io/). For more information on setting up authentication for SOARCA, please refer to the documentation [here](/docs/installation-configuration/authentication/). Authentication only works when enabling OIDC, as such if you want to have authentication you are required to setup Authentik or a different OIDC-provider. Note, that other OIDC-providers have not been tested yet. ## Setting up SOARCA-GUI with SOARCA From 1653d36cfc5d37299dabd6908d88d95e03cfa2c2 Mon Sep 17 00:00:00 2001 From: Maarten de Kruijf <16100232+MaartendeKruijf@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:52:44 +0200 Subject: [PATCH 09/10] Removed redundant ascii diagram --- .../authentication/entra.md | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/docs/content/en/docs/installation-configuration/authentication/entra.md b/docs/content/en/docs/installation-configuration/authentication/entra.md index 052f2f76..03fd44f8 100644 --- a/docs/content/en/docs/installation-configuration/authentication/entra.md +++ b/docs/content/en/docs/installation-configuration/authentication/entra.md @@ -11,20 +11,8 @@ SOARCA accepts access tokens that Entra ID issues for a dedicated **SOARCA API** You need an account that can create app registrations and grant admin consent, for example *Application Administrator*. Note your **tenant ID** from the Entra admin center overview page. -## How it fits together +## Entra components: users and applications -``` -SOARCA API (app registration) - Application ID URI api://soarca - Scope access_as_user - App roles Playbook.Read, Playbook.Write, Playbook.Execute, SOARCA.Admin - ^ ^ - | delegated scope | application permission - | + the user's assigned app roles | (app role assigned to the application) - | | -A client that signs users in An integration or managed identity -(authorization code flow) (client credentials flow) -``` ```plantuml @startuml skinparam componentStyle rectangle From 643d9f36aad2efe197894c279f430ac6cb1d534d Mon Sep 17 00:00:00 2001 From: Maarten de Kruijf <16100232+MaartendeKruijf@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:18:25 +0200 Subject: [PATCH 10/10] Removed old GUI config as it will be replaced with a new OIDC --- .../en/docs/installation-configuration/_index.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/docs/content/en/docs/installation-configuration/_index.md b/docs/content/en/docs/installation-configuration/_index.md index 59cd7b1a..cd145d5c 100644 --- a/docs/content/en/docs/installation-configuration/_index.md +++ b/docs/content/en/docs/installation-configuration/_index.md @@ -73,18 +73,6 @@ More information on setting up authentication can be found [here](/docs/installa | AUTH_ENTRA_AUTHORITY | `https://login.microsoftonline.com` | Entra ID login host; change only for national clouds. | | AUTH_API_KEYS_FILE | `/etc/soarca/api-keys.json` | JSON file with hashed API keys. Empty disables API keys. | -`OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URL` and `COOKIE_SECRET_KEY` are used by [SOARCA-GUI](/docs/soarca-gui/) for signing users in; SOARCA itself doesn't read them. - -------------------------|---------------------------------------------|---------------------------------------------------------------------------------------------| -| AUTH_ENABLED | `false` | Enable authentication. Default is `false`. | -| OIDC_ISSUER | `https://localhost:9443/application/u/test/` | The OIDC issuer URL. | -| OIDC_CLIENT_ID | `SOME_CLIENT_ID` | Set the OIDC client ID. | -| OIDC_CLIENT_SECRET | `SOME_CLIENT_SECRET` | Set the OIDC client secret. | -| OIDC_REDIRECT_URL | `http://localhost:8081/auth/soarca_gui/callback` | Set the OIDC redirect URL. | -| COOKIE_SECRET_KEY | `SOME_COOKIE_SECRET` | Optional: Secret key for cookies. Generate using `openssl rand -base64 32` or `head -c 32 /dev/urandom | base64`. | -| OIDC_SKIP_TLS_VERIFY | `true` | Set whether to skip TLS verification. Default is `true`. | -| AUTH_GROUP | `soarca_admin` | Specify the group users must belong to for authentication against SOARCA. | - ----- #### Key management system