diff --git a/docs/configuration/providers/saml/index.md b/docs/configuration/providers/saml/index.md index b1c8203c..dec2735d 100644 --- a/docs/configuration/providers/saml/index.md +++ b/docs/configuration/providers/saml/index.md @@ -67,7 +67,7 @@ openssl req -new -x509 -key saml.key -out saml.cert -days 365 \ 1. **Register Service Provider**: Add your agent as a Service Provider in your IdP 2. **Configure Entity ID**: Use your chosen entity ID (e.g., `https://your-app.example.com/saml/metadata`) -3. **Set Assertion Consumer Service**: Configure ACS URL (e.g., `https://your-app.example.com/saml/acs`) +3. **Set Assertion Consumer Service**: Configure ACS URL (e.g., `https://your-app.example.com/api/v1/auth/callback/{provider-name}`), replacing `{provider-name}` with the key you use for this provider (e.g., `company-saml`) 4. **Upload Certificate**: Upload your public certificate to the IdP ## Example Configurations diff --git a/docs/docs.go b/docs/docs.go index ca7e3fcc..92e0fab7 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -101,7 +101,7 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Authentication callback", + "summary": "OAuth2 authentication callback", "parameters": [ { "type": "string", @@ -137,6 +137,53 @@ const docTemplate = `{ } } } + }, + "post": { + "description": "Handle the SAML POST callback from the provider", + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "SAML authentication callback", + "parameters": [ + { + "type": "string", + "description": "Provider name", + "name": "provider", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "SAML RelayState (SP-initiated)", + "name": "RelayState", + "in": "formData" + }, + { + "type": "string", + "description": "SAML Response", + "name": "SAMLResponse", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "Authentication successful" + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } } }, "/auth/logout": { @@ -1149,7 +1196,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "Provider roles", + "description": "Provider identities", "schema": { "$ref": "#/definitions/models.ProviderIdentitiesResponse" } @@ -2026,6 +2073,14 @@ const docTemplate = `{ } ] }, + "thand": { + "description": "This is ONLY if the agent is running in server mode\nand you want to use https://www.thand.io hosted services", + "allOf": [ + { + "$ref": "#/definitions/github_com_thand-io_agent_internal_models.ThandConfig" + } + ] + }, "workflows": { "description": "These are workflows to run for role associated workflows", "allOf": [ @@ -2352,10 +2407,6 @@ const docTemplate = `{ "github_com_thand-io_agent_internal_models.LoginConfig": { "type": "object", "properties": { - "api_key": { - "description": "API key for authenticating with the login server", - "type": "string" - }, "base": { "description": "Base path for login endpoint e.g. /", "type": "string", @@ -2449,6 +2500,9 @@ const docTemplate = `{ "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Role" } ] + }, + "version": { + "$ref": "#/definitions/version.Version" } } }, @@ -2476,6 +2530,23 @@ const docTemplate = `{ } } }, + "github_com_thand-io_agent_internal_models.Resources": { + "type": "object", + "properties": { + "allow": { + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "github_com_thand-io_agent_internal_models.Role": { "type": "object", "properties": { @@ -2531,7 +2602,7 @@ const docTemplate = `{ "description": "resource access rules, apis, files, systems etc", "allOf": [ { - "$ref": "#/definitions/models.Resources" + "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Resources" } ] }, @@ -2543,6 +2614,9 @@ const docTemplate = `{ } ] }, + "version": { + "$ref": "#/definitions/version.Version" + }, "workflows": { "description": "The workflows to execute", "type": "array", @@ -2580,6 +2654,9 @@ const docTemplate = `{ "properties": { "cors": { "$ref": "#/definitions/github_com_thand-io_agent_internal_models.CORSConfig" + }, + "saml": { + "$ref": "#/definitions/models.SAMLSecurityConfig" } } }, @@ -2624,6 +2701,13 @@ const docTemplate = `{ "requests_per_minute": { "type": "integer" }, + "saml_burst": { + "type": "integer" + }, + "saml_rate_limit": { + "description": "SAML-specific rate limiting", + "type": "number" + }, "write_timeout": { "$ref": "#/definitions/time.Duration" } @@ -2674,6 +2758,29 @@ const docTemplate = `{ } } }, + "github_com_thand-io_agent_internal_models.ThandConfig": { + "type": "object", + "properties": { + "api_key": { + "description": "The API key for authenticating with Thand.io", + "type": "string" + }, + "base": { + "description": "Base path for login endpoint e.g. /", + "type": "string", + "default": "/" + }, + "endpoint": { + "type": "string", + "default": "https://app.thand.io/" + }, + "sync": { + "description": "Whether to enable synchronization with Thand.io", + "type": "boolean", + "default": true + } + } + }, "github_com_thand-io_agent_internal_models.User": { "type": "object", "properties": { @@ -2724,6 +2831,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "version": { + "$ref": "#/definitions/version.Version" + }, "workflow": { "$ref": "#/definitions/model.Workflow" } @@ -2757,7 +2867,7 @@ const docTemplate = `{ "identities": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Identity" } }, "input": { @@ -3754,7 +3864,21 @@ const docTemplate = `{ "identities": { "type": "array", "items": { - "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Identity" + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "_reason": { + "type": "string" + }, + "_score": { + "type": "number" + }, + "_source": { + "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Identity" + } + } } }, "provider": { @@ -3771,6 +3895,9 @@ const docTemplate = `{ "description": { "type": "string" }, + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -3785,7 +3912,21 @@ const docTemplate = `{ "permissions": { "type": "array", "items": { - "$ref": "#/definitions/models.ProviderPermission" + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "_reason": { + "type": "string" + }, + "_score": { + "type": "number" + }, + "_source": { + "$ref": "#/definitions/models.ProviderPermission" + } + } } }, "provider": { @@ -3805,6 +3946,9 @@ const docTemplate = `{ "enabled": { "type": "boolean" }, + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -3840,7 +3984,21 @@ const docTemplate = `{ "roles": { "type": "array", "items": { - "$ref": "#/definitions/models.ProviderRole" + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "_reason": { + "type": "string" + }, + "_score": { + "type": "number" + }, + "_source": { + "$ref": "#/definitions/models.ProviderRole" + } + } } }, "version": { @@ -3862,23 +4020,6 @@ const docTemplate = `{ } } }, - "models.Resources": { - "type": "object", - "properties": { - "allow": { - "type": "array", - "items": { - "type": "string" - } - }, - "deny": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "models.RoleResponse": { "type": "object", "properties": { @@ -3934,7 +4075,7 @@ const docTemplate = `{ "description": "resource access rules, apis, files, systems etc", "allOf": [ { - "$ref": "#/definitions/models.Resources" + "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Resources" } ] }, @@ -3946,6 +4087,9 @@ const docTemplate = `{ } ] }, + "version": { + "$ref": "#/definitions/version.Version" + }, "workflows": { "description": "The workflows to execute", "type": "array", @@ -3969,6 +4113,23 @@ const docTemplate = `{ } } }, + "models.SAMLSecurityConfig": { + "type": "object", + "properties": { + "assertion_cache_cleanup": { + "$ref": "#/definitions/time.Duration" + }, + "assertion_cache_ttl": { + "$ref": "#/definitions/time.Duration" + }, + "csrf_enabled": { + "type": "boolean" + }, + "session_duration": { + "$ref": "#/definitions/time.Duration" + } + } + }, "models.ServiceConfig": { "type": "object", "properties": { @@ -4105,6 +4266,9 @@ const docTemplate = `{ "Minute", "Hour" ] + }, + "version.Version": { + "type": "object" } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index eb1f7fcb..83420810 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -99,7 +99,7 @@ "tags": [ "auth" ], - "summary": "Authentication callback", + "summary": "OAuth2 authentication callback", "parameters": [ { "type": "string", @@ -135,6 +135,53 @@ } } } + }, + "post": { + "description": "Handle the SAML POST callback from the provider", + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "SAML authentication callback", + "parameters": [ + { + "type": "string", + "description": "Provider name", + "name": "provider", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "SAML RelayState (SP-initiated)", + "name": "RelayState", + "in": "formData" + }, + { + "type": "string", + "description": "SAML Response", + "name": "SAMLResponse", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "Authentication successful" + }, + "400": { + "description": "Bad request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } } }, "/auth/logout": { @@ -1147,7 +1194,7 @@ ], "responses": { "200": { - "description": "Provider roles", + "description": "Provider identities", "schema": { "$ref": "#/definitions/models.ProviderIdentitiesResponse" } @@ -2024,6 +2071,14 @@ } ] }, + "thand": { + "description": "This is ONLY if the agent is running in server mode\nand you want to use https://www.thand.io hosted services", + "allOf": [ + { + "$ref": "#/definitions/github_com_thand-io_agent_internal_models.ThandConfig" + } + ] + }, "workflows": { "description": "These are workflows to run for role associated workflows", "allOf": [ @@ -2350,10 +2405,6 @@ "github_com_thand-io_agent_internal_models.LoginConfig": { "type": "object", "properties": { - "api_key": { - "description": "API key for authenticating with the login server", - "type": "string" - }, "base": { "description": "Base path for login endpoint e.g. /", "type": "string", @@ -2447,6 +2498,9 @@ "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Role" } ] + }, + "version": { + "$ref": "#/definitions/version.Version" } } }, @@ -2474,6 +2528,23 @@ } } }, + "github_com_thand-io_agent_internal_models.Resources": { + "type": "object", + "properties": { + "allow": { + "type": "array", + "items": { + "type": "string" + } + }, + "deny": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "github_com_thand-io_agent_internal_models.Role": { "type": "object", "properties": { @@ -2529,7 +2600,7 @@ "description": "resource access rules, apis, files, systems etc", "allOf": [ { - "$ref": "#/definitions/models.Resources" + "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Resources" } ] }, @@ -2541,6 +2612,9 @@ } ] }, + "version": { + "$ref": "#/definitions/version.Version" + }, "workflows": { "description": "The workflows to execute", "type": "array", @@ -2578,6 +2652,9 @@ "properties": { "cors": { "$ref": "#/definitions/github_com_thand-io_agent_internal_models.CORSConfig" + }, + "saml": { + "$ref": "#/definitions/models.SAMLSecurityConfig" } } }, @@ -2622,6 +2699,13 @@ "requests_per_minute": { "type": "integer" }, + "saml_burst": { + "type": "integer" + }, + "saml_rate_limit": { + "description": "SAML-specific rate limiting", + "type": "number" + }, "write_timeout": { "$ref": "#/definitions/time.Duration" } @@ -2672,6 +2756,29 @@ } } }, + "github_com_thand-io_agent_internal_models.ThandConfig": { + "type": "object", + "properties": { + "api_key": { + "description": "The API key for authenticating with Thand.io", + "type": "string" + }, + "base": { + "description": "Base path for login endpoint e.g. /", + "type": "string", + "default": "/" + }, + "endpoint": { + "type": "string", + "default": "https://app.thand.io/" + }, + "sync": { + "description": "Whether to enable synchronization with Thand.io", + "type": "boolean", + "default": true + } + } + }, "github_com_thand-io_agent_internal_models.User": { "type": "object", "properties": { @@ -2722,6 +2829,9 @@ "name": { "type": "string" }, + "version": { + "$ref": "#/definitions/version.Version" + }, "workflow": { "$ref": "#/definitions/model.Workflow" } @@ -2755,7 +2865,7 @@ "identities": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Identity" } }, "input": { @@ -3752,7 +3862,21 @@ "identities": { "type": "array", "items": { - "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Identity" + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "_reason": { + "type": "string" + }, + "_score": { + "type": "number" + }, + "_source": { + "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Identity" + } + } } }, "provider": { @@ -3769,6 +3893,9 @@ "description": { "type": "string" }, + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -3783,7 +3910,21 @@ "permissions": { "type": "array", "items": { - "$ref": "#/definitions/models.ProviderPermission" + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "_reason": { + "type": "string" + }, + "_score": { + "type": "number" + }, + "_source": { + "$ref": "#/definitions/models.ProviderPermission" + } + } } }, "provider": { @@ -3803,6 +3944,9 @@ "enabled": { "type": "boolean" }, + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -3838,7 +3982,21 @@ "roles": { "type": "array", "items": { - "$ref": "#/definitions/models.ProviderRole" + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "_reason": { + "type": "string" + }, + "_score": { + "type": "number" + }, + "_source": { + "$ref": "#/definitions/models.ProviderRole" + } + } } }, "version": { @@ -3860,23 +4018,6 @@ } } }, - "models.Resources": { - "type": "object", - "properties": { - "allow": { - "type": "array", - "items": { - "type": "string" - } - }, - "deny": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, "models.RoleResponse": { "type": "object", "properties": { @@ -3932,7 +4073,7 @@ "description": "resource access rules, apis, files, systems etc", "allOf": [ { - "$ref": "#/definitions/models.Resources" + "$ref": "#/definitions/github_com_thand-io_agent_internal_models.Resources" } ] }, @@ -3944,6 +4085,9 @@ } ] }, + "version": { + "$ref": "#/definitions/version.Version" + }, "workflows": { "description": "The workflows to execute", "type": "array", @@ -3967,6 +4111,23 @@ } } }, + "models.SAMLSecurityConfig": { + "type": "object", + "properties": { + "assertion_cache_cleanup": { + "$ref": "#/definitions/time.Duration" + }, + "assertion_cache_ttl": { + "$ref": "#/definitions/time.Duration" + }, + "csrf_enabled": { + "type": "boolean" + }, + "session_duration": { + "$ref": "#/definitions/time.Duration" + } + } + }, "models.ServiceConfig": { "type": "object", "properties": { @@ -4103,6 +4264,9 @@ "Minute", "Hour" ] + }, + "version.Version": { + "type": "object" } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1ef5fba4..9b48260f 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -106,6 +106,12 @@ definitions: allOf: - $ref: '#/definitions/github_com_thand-io_agent_internal_models.ServicesConfig' description: External services / non-core services + thand: + allOf: + - $ref: '#/definitions/github_com_thand-io_agent_internal_models.ThandConfig' + description: |- + This is ONLY if the agent is running in server mode + and you want to use https://www.thand.io hosted services workflows: allOf: - $ref: '#/definitions/github_com_thand-io_agent_internal_config.WorkflowConfig' @@ -330,9 +336,6 @@ definitions: type: object github_com_thand-io_agent_internal_models.LoginConfig: properties: - api_key: - description: API key for authenticating with the login server - type: string base: default: / description: Base path for login endpoint e.g. / @@ -404,6 +407,8 @@ definitions: allOf: - $ref: '#/definitions/github_com_thand-io_agent_internal_models.Role' description: The base role for this provider + version: + $ref: '#/definitions/version.Version' type: object github_com_thand-io_agent_internal_models.RateLimitConfig: properties: @@ -421,6 +426,17 @@ definitions: default: /ready type: string type: object + github_com_thand-io_agent_internal_models.Resources: + properties: + allow: + items: + type: string + type: array + deny: + items: + type: string + type: array + type: object github_com_thand-io_agent_internal_models.Role: properties: authenticators: @@ -457,12 +473,14 @@ definitions: type: array resources: allOf: - - $ref: '#/definitions/models.Resources' + - $ref: '#/definitions/github_com_thand-io_agent_internal_models.Resources' description: resource access rules, apis, files, systems etc scopes: allOf: - $ref: '#/definitions/github_com_thand-io_agent_internal_models.RoleScopes' description: scope of who can be assigned this role + version: + $ref: '#/definitions/version.Version' workflows: description: The workflows to execute items: @@ -488,6 +506,8 @@ definitions: properties: cors: $ref: '#/definitions/github_com_thand-io_agent_internal_models.CORSConfig' + saml: + $ref: '#/definitions/models.SAMLSecurityConfig' type: object github_com_thand-io_agent_internal_models.ServerConfig: properties: @@ -516,6 +536,11 @@ definitions: $ref: '#/definitions/time.Duration' requests_per_minute: type: integer + saml_burst: + type: integer + saml_rate_limit: + description: SAML-specific rate limiting + type: number write_timeout: $ref: '#/definitions/time.Duration' type: object @@ -542,6 +567,23 @@ definitions: - $ref: '#/definitions/models.ServiceConfig' description: Vault - used for storing sensitive data type: object + github_com_thand-io_agent_internal_models.ThandConfig: + properties: + api_key: + description: The API key for authenticating with Thand.io + type: string + base: + default: / + description: Base path for login endpoint e.g. / + type: string + endpoint: + default: https://app.thand.io/ + type: string + sync: + default: true + description: Whether to enable synchronization with Thand.io + type: boolean + type: object github_com_thand-io_agent_internal_models.User: properties: email: @@ -580,6 +622,8 @@ definitions: type: boolean name: type: string + version: + $ref: '#/definitions/version.Version' workflow: $ref: '#/definitions/model.Workflow' type: object @@ -603,7 +647,7 @@ definitions: type: string identities: items: - type: string + $ref: '#/definitions/github_com_thand-io_agent_internal_models.Identity' type: array input: description: Context @@ -1275,7 +1319,16 @@ definitions: properties: identities: items: - $ref: '#/definitions/github_com_thand-io_agent_internal_models.Identity' + properties: + _id: + type: string + _reason: + type: string + _score: + type: number + _source: + $ref: '#/definitions/github_com_thand-io_agent_internal_models.Identity' + type: object type: array provider: type: string @@ -1286,6 +1339,8 @@ definitions: properties: description: type: string + id: + type: string name: type: string title: @@ -1295,7 +1350,16 @@ definitions: properties: permissions: items: - $ref: '#/definitions/models.ProviderPermission' + properties: + _id: + type: string + _reason: + type: string + _score: + type: number + _source: + $ref: '#/definitions/models.ProviderPermission' + type: object type: array provider: type: string @@ -1308,6 +1372,8 @@ definitions: type: string enabled: type: boolean + id: + type: string name: type: string provider: @@ -1331,7 +1397,16 @@ definitions: type: string roles: items: - $ref: '#/definitions/models.ProviderRole' + properties: + _id: + type: string + _reason: + type: string + _score: + type: number + _source: + $ref: '#/definitions/models.ProviderRole' + type: object type: array version: type: string @@ -1345,17 +1420,6 @@ definitions: version: type: string type: object - models.Resources: - properties: - allow: - items: - type: string - type: array - deny: - items: - type: string - type: array - type: object models.RoleResponse: properties: authenticators: @@ -1392,12 +1456,14 @@ definitions: type: array resources: allOf: - - $ref: '#/definitions/models.Resources' + - $ref: '#/definitions/github_com_thand-io_agent_internal_models.Resources' description: resource access rules, apis, files, systems etc scopes: allOf: - $ref: '#/definitions/github_com_thand-io_agent_internal_models.RoleScopes' description: scope of who can be assigned this role + version: + $ref: '#/definitions/version.Version' workflows: description: The workflows to execute items: @@ -1413,6 +1479,17 @@ definitions: version: type: string type: object + models.SAMLSecurityConfig: + properties: + assertion_cache_cleanup: + $ref: '#/definitions/time.Duration' + assertion_cache_ttl: + $ref: '#/definitions/time.Duration' + csrf_enabled: + type: boolean + session_duration: + $ref: '#/definitions/time.Duration' + type: object models.ServiceConfig: properties: config: @@ -1513,6 +1590,8 @@ definitions: - Second - Minute - Hour + version.Version: + type: object host: localhost:8080 info: contact: @@ -1602,7 +1681,39 @@ paths: schema: additionalProperties: true type: object - summary: Authentication callback + summary: OAuth2 authentication callback + tags: + - auth + post: + consumes: + - application/x-www-form-urlencoded + description: Handle the SAML POST callback from the provider + parameters: + - description: Provider name + in: path + name: provider + required: true + type: string + - description: SAML RelayState (SP-initiated) + in: formData + name: RelayState + type: string + - description: SAML Response + in: formData + name: SAMLResponse + required: true + type: string + produces: + - application/json + responses: + "200": + description: Authentication successful + "400": + description: Bad request + schema: + additionalProperties: true + type: object + summary: SAML authentication callback tags: - auth /auth/logout: @@ -2275,7 +2386,7 @@ paths: - application/json responses: "200": - description: Provider roles + description: Provider identities schema: $ref: '#/definitions/models.ProviderIdentitiesResponse' "404": diff --git a/examples/providers/saml.example.yaml b/examples/providers/saml.example.yaml index fb1b0452..b276f49a 100644 --- a/examples/providers/saml.example.yaml +++ b/examples/providers/saml.example.yaml @@ -10,7 +10,7 @@ providers: # Required: URL to fetch IdP metadata idp_metadata_url: "https://your-idp.example.com/saml/metadata" - # Required: Entity ID for this service provider + # Required: Entity ID for this service provider (typically the metadata URL) entity_id: "https://your-app.example.com/saml/metadata" # Required: Root URL of your application @@ -24,3 +24,6 @@ providers: # Optional: Whether to sign SAML requests (default: false) sign_requests: true + + # Optional: Allow IdP-initiated SSO (default: false) + allow_idp_initiated: true \ No newline at end of file diff --git a/go.mod b/go.mod index 72b890cc..b5249688 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/google/flatbuffers v25.9.23+incompatible github.com/google/go-github/v57 v57.0.0 github.com/google/uuid v1.6.0 + github.com/gorilla/csrf v1.7.3 github.com/hashicorp/go-tfe v1.97.0 github.com/hashicorp/go-version v1.8.0 github.com/hashicorp/vault/api v1.22.0 @@ -65,6 +66,7 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 + github.com/ulule/limiter/v3 v3.11.2 go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 go.opentelemetry.io/otel/log v0.15.0 diff --git a/go.sum b/go.sum index d135130c..8c615a31 100644 --- a/go.sum +++ b/go.sum @@ -367,6 +367,8 @@ github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81 github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= +github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= @@ -633,6 +635,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA= +github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= diff --git a/internal/config/providers.go b/internal/config/providers.go index f9d9b6dc..43b6aa75 100644 --- a/internal/config/providers.go +++ b/internal/config/providers.go @@ -22,6 +22,7 @@ import ( _ "github.com/thand-io/agent/internal/providers/oauth2.google" _ "github.com/thand-io/agent/internal/providers/okta" _ "github.com/thand-io/agent/internal/providers/salesforce" + _ "github.com/thand-io/agent/internal/providers/saml" _ "github.com/thand-io/agent/internal/providers/slack" _ "github.com/thand-io/agent/internal/providers/terraform" _ "github.com/thand-io/agent/internal/providers/thand" diff --git a/internal/daemon/assertion_cache.go b/internal/daemon/assertion_cache.go new file mode 100644 index 00000000..88375cea --- /dev/null +++ b/internal/daemon/assertion_cache.go @@ -0,0 +1,142 @@ +package daemon + +import ( + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +// AssertionCache implements in-memory cache for SAML assertion ID replay protection. +// It provides thread-safe tracking of used assertion IDs to prevent replay attacks +// where an attacker captures a valid SAML assertion and attempts to reuse it. +type AssertionCache struct { + cache sync.Map // map[string]*assertionEntry + ttl time.Duration // Time-to-live for cached assertions + cleanupTicker *time.Ticker // Periodic cleanup ticker + stopCleanup chan struct{} // Channel to stop cleanup goroutine +} + +// assertionEntry represents a cached assertion with its timing information +type assertionEntry struct { + addedAt time.Time // When the assertion was first cached + expiry time.Time // When the assertion entry expires +} + +// NewAssertionCache creates a new assertion cache with the specified TTL and cleanup interval. +// The TTL should match the typical validity window of SAML assertions (usually 5 minutes). +// The cleanup interval determines how often expired entries are removed from memory. +func NewAssertionCache(ttl time.Duration, cleanupInterval time.Duration) *AssertionCache { + if ttl == 0 { + ttl = 5 * time.Minute // Default TTL matches typical SAML assertion validity + } + if cleanupInterval == 0 { + cleanupInterval = 1 * time.Minute // Default cleanup every minute + } + + ac := &AssertionCache{ + ttl: ttl, + stopCleanup: make(chan struct{}), + } + + // Start cleanup goroutine + ac.cleanupTicker = time.NewTicker(cleanupInterval) + go ac.cleanup() + + logrus.WithFields(logrus.Fields{ + "ttl": ttl, + "cleanup_interval": cleanupInterval, + }).Info("Assertion cache initialized") + + return ac +} + +// CheckAndAdd atomically checks if an assertion ID exists in the cache and adds it if not. +// This method is the core of replay protection - it ensures that each assertion ID can +// only be used once within the TTL window. +// +// Returns true if the assertion was added (not a replay), false if it already exists (replay detected). +func (ac *AssertionCache) CheckAndAdd(assertionID string) bool { + if assertionID == "" { + logrus.Warn("Empty assertion ID provided to cache") + return false + } + + now := time.Now() + entry := &assertionEntry{ + addedAt: now, + expiry: now.Add(ac.ttl), + } + + // LoadOrStore is atomic - it returns the existing value if present, + // or stores the new value and returns it. The 'loaded' bool indicates + // whether the value was loaded (true) or stored (false). + _, loaded := ac.cache.LoadOrStore(assertionID, entry) + + if loaded { + // Assertion ID already exists - this is a replay attack + logrus.WithFields(logrus.Fields{ + "assertion_id": assertionID, + "event": "replay_detected", + }).Warn("SAML assertion replay attack detected") + return false + } + + // Successfully cached new assertion ID + logrus.WithFields(logrus.Fields{ + "assertion_id": assertionID, + "expiry": entry.expiry, + }).Debug("SAML assertion ID cached successfully") + + return true +} + +// cleanup removes expired assertion entries from the cache. +// This goroutine runs periodically based on the cleanup interval and prevents +// unbounded memory growth by removing entries that have exceeded their TTL. +func (ac *AssertionCache) cleanup() { + for { + select { + case <-ac.cleanupTicker.C: + now := time.Now() + count := 0 + + // Iterate through all cache entries + ac.cache.Range(func(key, value interface{}) bool { + entry := value.(*assertionEntry) + if now.After(entry.expiry) { + ac.cache.Delete(key) + count++ + } + return true // Continue iteration + }) + + if count > 0 { + logrus.WithField("count", count).Debug("Cleaned up expired assertion cache entries") + } + + case <-ac.stopCleanup: + // Graceful shutdown requested + ac.cleanupTicker.Stop() + logrus.Info("Assertion cache cleanup goroutine stopped") + return + } + } +} + +// Stop gracefully stops the cleanup goroutine. +// This should be called when the server is shutting down to prevent goroutine leaks. +func (ac *AssertionCache) Stop() { + close(ac.stopCleanup) +} + +// Size returns the current number of cached assertions. +// This is useful for monitoring and observability to track cache utilization. +func (ac *AssertionCache) Size() int { + count := 0 + ac.cache.Range(func(_, _ interface{}) bool { + count++ + return true + }) + return count +} diff --git a/internal/daemon/auth.go b/internal/daemon/auth.go index c0bacec2..a586d88f 100644 --- a/internal/daemon/auth.go +++ b/internal/daemon/auth.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "net/url" - "strings" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -15,6 +14,21 @@ import ( "github.com/thand-io/agent/internal/models" ) +// Authentication Callback Handlers +// +// This file implements two separate callback handlers: +// +// 1. getAuthCallback() - OAuth2 GET callbacks +// - Expects state and code in query parameters +// - Used by OAuth2 providers (GitHub, Google, etc.) +// +// 2. postAuthCallback() - SAML POST callbacks +// - Expects RelayState and SAMLResponse in form parameters +// - Supports SP-initiated (with RelayState) and IdP-initiated (no RelayState) +// - Used by SAML providers (Okta, Azure AD, etc.) +// +// Both handlers delegate to getAuthCallbackPage() for session creation. + // getAuthRequest initiates the authentication flow // // @Summary Initiate authentication @@ -42,9 +56,38 @@ func (s *Server) getAuthRequest(c *gin.Context) { config := s.GetConfig() - if len(callback) > 0 && strings.Compare(callback, config.GetLoginServerUrl()) == 0 { - s.getErrorPage(c, http.StatusBadRequest, "Callback cannot be the login server") - return + // Validate callback URL to prevent infinite loops + // Only block callbacks that would loop back to the auth request endpoint + if len(callback) > 0 { + callbackURL, callbackErr := url.Parse(callback) + loginServerURL, loginServerErr := url.Parse(config.GetLoginServerUrl()) + + if callbackErr == nil && loginServerErr == nil { + // Block if it's the same host, path, and query as the current request (i.e., would cause a loop) + currentPath := c.Request.URL.Path + + // Remove the "callback" parameter from the current query for comparison + currentQueryVals := c.Request.URL.Query() + currentQueryVals.Del("callback") + currentQuery := currentQueryVals.Encode() + callbackPath := callbackURL.Path + callbackQueryVals := callbackURL.Query() + callbackQueryVals.Del("callback") + callbackQuery := callbackQueryVals.Encode() + + if callbackURL.Host == loginServerURL.Host && + callbackPath == currentPath && + callbackQuery == currentQuery { + s.getErrorPage(c, http.StatusBadRequest, "Callback cannot be the same as the current auth request endpoint - this would create an infinite loop") + } + } else { + // If we can't parse the URLs, log the error but allow the request to proceed + logrus.WithFields(logrus.Fields{ + "callback": callback, + "callbackErr": callbackErr, + "loginServerErr": loginServerErr, + }).Warnln("Failed to parse callback or login server URL for validation") + } } logrus.WithFields(logrus.Fields{ @@ -63,22 +106,29 @@ func (s *Server) getAuthRequest(c *gin.Context) { client := common.GetClientIdentifier() + encodedState := models.EncodingWrapper{ + Type: models.ENCODED_AUTH, + Data: models.NewAuthWrapper( + callback, // where are we returning to + client.String(), // server identifier + provider, // provider name + code, // the code sent by the client + ), + }.EncodeAndEncrypt( + s.Config.GetServices().GetEncryption(), + ) + + // Log state length only for debugging - avoid logging full encrypted state for security + logrus.WithFields(logrus.Fields{ + "stateLength": len(encodedState), + }).Debugln("Encoded state for auth request") + authResponse, err := providerConfig.GetClient().AuthorizeSession( context.Background(), // This creates the state payload for the auth request &models.AuthorizeUser{ - Scopes: []string{"email", "profile"}, - State: models.EncodingWrapper{ - Type: models.ENCODED_AUTH, - Data: models.NewAuthWrapper( - callback, // where are we returning to - client.String(), // server identifier - provider, // provider name - code, // the code sent by the client - ), - }.EncodeAndEncrypt( - s.Config.GetServices().GetEncryption(), - ), + Scopes: []string{"email", "profile"}, + State: encodedState, RedirectUri: s.GetConfig().GetAuthCallbackUrl(provider), }, ) @@ -94,9 +144,9 @@ func (s *Server) getAuthRequest(c *gin.Context) { ) } -// getAuthCallback handles the OAuth2 callback +// getAuthCallback handles OAuth2 GET callback requests // -// @Summary Authentication callback +// @Summary OAuth2 authentication callback // @Description Handle the OAuth2 callback from the provider // @Tags auth // @Accept json @@ -108,34 +158,149 @@ func (s *Server) getAuthRequest(c *gin.Context) { // @Failure 400 {object} map[string]any "Bad request" // @Router /auth/callback/{provider} [get] func (s *Server) getAuthCallback(c *gin.Context) { + // OAuth2 flow: state and code come in query parameters (GET) + state := c.Query("state") - // Handle the callback to the CLI to store the users session state + // Debug logging + logrus.WithFields(logrus.Fields{ + "method": c.Request.Method, + "state": state, + }).Debugln("OAuth2 callback parameters") - // Check if the callback is a workflow resumption or - // a local callback response + // Validate state parameter is required for OAuth2 + if len(state) == 0 { + s.getErrorPage(c, http.StatusBadRequest, "State is required for OAuth2 flow") + return + } - state := c.Query("state") + // Decode and decrypt state + decoded, err := s.decodeState(state) + if err != nil { + s.getErrorPage(c, http.StatusBadRequest, "Invalid state", err) + return + } - if len(state) == 0 { - s.getErrorPage(c, http.StatusBadRequest, "State is required") + // Process decoded state + s.processDecodedState(c, decoded) +} + +// postAuthCallback handles SAML POST callback requests +// +// @Summary SAML authentication callback +// @Description Handle the SAML POST callback from the provider +// @Tags auth +// @Accept x-www-form-urlencoded +// @Produce json +// @Param provider path string true "Provider name" +// @Param RelayState formData string false "SAML RelayState (SP-initiated)" +// @Param SAMLResponse formData string true "SAML Response" +// @Success 200 "Authentication successful" +// @Failure 400 {object} map[string]any "Bad request" +// @Router /auth/callback/{provider} [post] +func (s *Server) postAuthCallback(c *gin.Context) { + log := LogWithCorrelation(c) + + // SAML flow: RelayState and SAMLResponse come in form parameters (POST) + relayState := c.PostForm("RelayState") + samlResponse := c.PostForm("SAMLResponse") + + // Debug logging with correlation ID + log.WithFields(logrus.Fields{ + "method": c.Request.Method, + "relay_state": relayState, + "has_response": len(samlResponse) > 0, + }).Debugln("SAML callback parameters") + + // Handle IdP-initiated SAML flow (no RelayState parameter) + if len(relayState) == 0 { + // Check if this is a SAML callback with SAMLResponse + if len(samlResponse) > 0 { + // IdP-initiated flow: validate provider allows this + providerName := c.Param("provider") + + // Get provider config to verify IdP-initiated is allowed + providerConfig, err := s.GetConfig().GetProviderByName(providerName) + if err != nil { + logrus.WithFields(logrus.Fields{ + "provider": providerName, + "error": err, + }).Warn("Provider not found for IdP-initiated SAML flow") + s.getErrorPage(c, http.StatusBadRequest, "Provider not configured") + return + } + + // Check if provider explicitly allows IdP-initiated flows (opt-in security) + // Administrator must set "allow_idp_initiated: true" in provider config + if allowIDPInitiated, ok := providerConfig.Config.GetBool("allow_idp_initiated"); !ok || !allowIDPInitiated { + logrus.WithFields(logrus.Fields{ + "provider": providerName, + "source_ip": c.ClientIP(), + "user_agent": c.Request.UserAgent(), + }).Warn("IdP-initiated SAML flow rejected - not enabled in provider config (set allow_idp_initiated: true)") + s.getErrorPage(c, http.StatusForbidden, "IdP-initiated SAML flows are not enabled for this provider") + return + } + + // Security logging for IdP-initiated flows (for audit/monitoring) + log.WithFields(logrus.Fields{ + "provider": providerName, + "source_ip": c.ClientIP(), + "user_agent": c.Request.UserAgent(), + "flow": "idp-initiated", + }).Warn("Processing IdP-initiated SAML authentication (allow_idp_initiated=true)") + + // Note: CSRF protection is NOT applicable for IdP-initiated SAML flows + // because the flow starts at the IdP, not our application. The user never + // visits our site first to get a CSRF token. Instead, security relies on: + // 1. SAML Response signature validation (cryptographically signed by IdP) + // 2. Assertion validation (audience, destination, timestamps) + // 3. Assertion ID replay protection (prevents reuse) + // These protections are implemented in the SAML provider's CreateSession method. + + authWrapper := models.AuthWrapper{ + Callback: "", // No callback for IdP-initiated + Provider: providerName, + Code: "", // No client code + Client: "", // No client identifier + } + s.getAuthCallbackPage(c, authWrapper) + return + } + + // Not a SAML IdP-initiated flow, RelayState is required + s.getErrorPage(c, http.StatusBadRequest, "RelayState is required for SP-initiated SAML flow") + return + } + + // SP-initiated flow: decode and decrypt RelayState + decoded, err := s.decodeState(relayState) + if err != nil { + s.getErrorPage(c, http.StatusBadRequest, "Invalid RelayState", err) return } + // Process decoded state + s.processDecodedState(c, decoded) +} + +// decodeState decodes and decrypts the state parameter +func (s *Server) decodeState(state string) (models.EncodingWrapper, error) { decoded, err := models.EncodingWrapper{}.DecodeAndDecrypt( state, s.Config.GetServices().GetEncryption(), ) - if err != nil { - s.getErrorPage(c, http.StatusBadRequest, "Invalid state", err) - return + return models.EncodingWrapper{}, fmt.Errorf("failed to decode state: %w", err) } + return *decoded, nil +} +// processDecodedState routes based on decoded state type +func (s *Server) processDecodedState(c *gin.Context, decoded models.EncodingWrapper) { switch decoded.Type { case models.ENCODED_WORKFLOW_TASK: s.getElevateAuthOAuth2(c) case models.ENCODED_AUTH: - authWrapper := models.AuthWrapper{} err := common.ConvertMapToInterface( decoded.Data.(map[string]any), &authWrapper) @@ -146,7 +311,6 @@ func (s *Server) getAuthCallback(c *gin.Context) { } s.getAuthCallbackPage(c, authWrapper) - default: s.getErrorPage(c, http.StatusBadRequest, "Invalid state type") } @@ -240,6 +404,7 @@ type AuthCallbackPageData struct { } func (s *Server) getAuthCallbackPage(c *gin.Context, auth models.AuthWrapper) { + log := LogWithCorrelation(c) // Get the provider and pull back the user session into // the context @@ -247,29 +412,51 @@ func (s *Server) getAuthCallbackPage(c *gin.Context, auth models.AuthWrapper) { provider, err := s.Config.GetProviderByName(auth.Provider) if err != nil { + log.WithError(err).WithField("provider", auth.Provider).Error("Invalid provider") s.getErrorPage(c, http.StatusBadRequest, "Invalid provider", err) return } + // For OAuth2: state and code come in query parameters (GET) + // For SAML: RelayState and SAMLResponse come in form parameters (POST) state := c.Query("state") + if len(state) == 0 { + state = c.PostForm("RelayState") + } + code := c.Query("code") // This is the code from the provider - not the client + if len(code) == 0 { + code = c.PostForm("SAMLResponse") + } - session, err := provider.GetClient().CreateSession(c, &models.AuthorizeUser{ + // Inject assertion cache into context for SAML replay protection + ctx := context.WithValue(c.Request.Context(), "assertion_cache", s.assertionCache) + + session, err := provider.GetClient().CreateSession(ctx, &models.AuthorizeUser{ State: state, Code: code, RedirectUri: s.GetConfig().GetAuthCallbackUrl(auth.Provider), }) if err != nil { + log.WithError(err).WithField("provider", auth.Provider).Error("Failed to create session") s.getErrorPage(c, http.StatusBadRequest, "Failed to create session", err) return } if session == nil { + log.WithField("provider", auth.Provider).Error("Session is nil after creation") s.getErrorPage(c, http.StatusInternalServerError, "Session is nil") return } + // SECURITY: Regenerate session to prevent session fixation attacks + if err := s.regenerateSession(c, auth.Provider); err != nil { + log.WithError(err).WithField("provider", auth.Provider).Error("Failed to regenerate session") + s.getErrorPage(c, http.StatusInternalServerError, "Session regeneration failed", err) + return + } + exportableSession := &models.ExportableSession{ Session: session, Provider: auth.Provider, @@ -287,12 +474,20 @@ func (s *Server) getAuthCallbackPage(c *gin.Context, auth models.AuthWrapper) { } if err := s.setAuthCookie(c, auth.Provider, localSession); err != nil { + log.WithError(err).WithField("provider", auth.Provider).Error("Failed to set auth cookie") s.getErrorPage(c, http.StatusInternalServerError, "Failed to set auth cookie", err) return } + log.WithFields(logrus.Fields{ + "provider": auth.Provider, + "session_id": session.UUID.String(), + }).Info("Authentication successful, session created") + if len(auth.Callback) == 0 { - c.Redirect(http.StatusTemporaryRedirect, "/") + // Use HTTP 303 (See Other) for POST-to-GET redirects - correct REST semantic + // Prevents form resubmission on browser refresh + c.Redirect(http.StatusSeeOther, "/") } else { s.renderHtml(c, "auth_callback.html", data) } @@ -378,3 +573,33 @@ func (s *Server) getLogoutPage(c *gin.Context) { } } + +// regenerateSession prevents session fixation attacks by clearing old sessions before authentication. +// This should be called after successful authentication but before setting the authenticated session cookie. +func (s *Server) regenerateSession(c *gin.Context, providerName string) error { + log := LogWithCorrelation(c) + + // Clear any existing session cookie for this provider + cookieName := CreateCookieName(providerName) + oldSession := sessions.DefaultMany(c, cookieName) + if oldSession != nil { + oldSession.Clear() + if err := oldSession.Save(); err != nil { + log.WithError(err).WithField("provider", providerName).Error("Failed to clear old provider session") + return fmt.Errorf("failed to clear old session: %w", err) + } + } + + // Also clear main thand cookie + mainSession := sessions.DefaultMany(c, ThandCookieName) + if mainSession != nil { + mainSession.Clear() + if err := mainSession.Save(); err != nil { + log.WithError(err).Error("Failed to clear main session") + return fmt.Errorf("failed to clear main session: %w", err) + } + } + + log.WithField("provider", providerName).Debug("Sessions regenerated successfully") + return nil +} diff --git a/internal/daemon/correlation.go b/internal/daemon/correlation.go new file mode 100644 index 00000000..100ca089 --- /dev/null +++ b/internal/daemon/correlation.go @@ -0,0 +1,69 @@ +package daemon + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +// correlationIDKey is the context key used to store the correlation ID +const correlationIDKey = "correlation_id" + +// CorrelationMiddleware adds a unique correlation ID to each request for distributed tracing. +// The correlation ID can be used to trace requests across services and correlate log entries. +// +// Priority: +// 1. Uses existing X-Correlation-ID header if present (for distributed tracing) +// 2. Generates a new UUID if no correlation ID exists +// +// The correlation ID is: +// - Stored in the gin context for access by handlers +// - Added to the response header for client-side tracing +func CorrelationMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Check if correlation ID already exists in request header + correlationID := c.GetHeader("X-Correlation-ID") + + // Generate new correlation ID if not present + if correlationID == "" { + correlationID = uuid.New().String() + } + + // Store in context for handler access + c.Set(correlationIDKey, correlationID) + + // Add to response header for tracing + c.Header("X-Correlation-ID", correlationID) + + c.Next() + } +} + +// GetCorrelationID retrieves the correlation ID from the request context. +// Returns an empty string if no correlation ID is found. +// +// This is useful for handlers that need to include the correlation ID +// in logs or pass it to downstream services. +func GetCorrelationID(c *gin.Context) string { + if id, exists := c.Get(correlationIDKey); exists { + if strID, ok := id.(string); ok { + return strID + } + } + return "" +} + +// LogWithCorrelation creates a logrus entry with the correlation ID automatically included. +// This ensures all log entries for a request can be correlated and traced. +// +// Usage: +// +// log := LogWithCorrelation(c) +// log.Info("Processing SAML callback") +// log.WithFields(logrus.Fields{...}).Warn("Security event detected") +// +// The correlation ID will automatically be included in all log entries created from this logger. +func LogWithCorrelation(c *gin.Context) *logrus.Entry { + correlationID := GetCorrelationID(c) + return logrus.WithField("correlation_id", correlationID) +} diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 5755df7b..cbcf9b50 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -41,6 +41,9 @@ import ( "github.com/thand-io/agent/internal/config" "github.com/thand-io/agent/internal/models" "github.com/thand-io/agent/internal/workflows/manager" + "github.com/ulule/limiter/v3" + mgin "github.com/ulule/limiter/v3/drivers/middleware/gin" + "github.com/ulule/limiter/v3/drivers/store/memory" "go.temporal.io/api/workflowservice/v1" ) @@ -68,12 +71,54 @@ func NewServer(cfg *config.Config) *Server { logrus.WithError(err).Fatal("Failed to parse templates") } + // Initialize rate limiter for SAML callbacks using ulule/limiter + // Rate: 5 requests per second with burst of 10 (configurable) + samlRate := 5.0 // Default: 5 requests/second + samlBurst := 10 // Default: 10 burst capacity (unused in this implementation, kept for config compatibility) + if cfg.Server.Limits.SAMLRateLimit != 0 { + samlRate = cfg.Server.Limits.SAMLRateLimit + } + if cfg.Server.Limits.SAMLBurst != 0 { + samlBurst = cfg.Server.Limits.SAMLBurst + } + + // Create rate limiter with in-memory store + // Format: "{requests}-S" means X requests per second + rate := limiter.Rate{ + Period: 1 * time.Second, + Limit: int64(samlRate), + } + store := memory.NewStore() + rateLimiterInstance := limiter.New(store, rate) + + // Create Gin middleware that limits by client IP + rateLimiterMiddleware := mgin.NewMiddleware(rateLimiterInstance) + + // Initialize assertion cache for SAML replay protection + cacheTTL := 5 * time.Minute // Default: 5 minutes + cacheCleanup := 1 * time.Minute // Default: 1 minute + if cfg.Server.Security.SAML.AssertionCacheTTL != 0 { + cacheTTL = cfg.Server.Security.SAML.AssertionCacheTTL + } + if cfg.Server.Security.SAML.AssertionCacheCleanup != 0 { + cacheCleanup = cfg.Server.Security.SAML.AssertionCacheCleanup + } + assertionCache := NewAssertionCache(cacheTTL, cacheCleanup) + + logrus.WithFields(logrus.Fields{ + "saml_rate_limit": samlRate, + "saml_burst": samlBurst, + "assertion_cache_ttl": cacheTTL, + }).Info("Security components initialized") + // Create a new server instance with the provided configuration server := &Server{ - Config: cfg, - TemplateEngine: tmpl, - Workflows: workflows, - StartTime: time.Now().UTC(), + Config: cfg, + TemplateEngine: tmpl, + Workflows: workflows, + StartTime: time.Now().UTC(), + rateLimiterMiddleware: rateLimiterMiddleware, + assertionCache: assertionCache, } return server @@ -88,6 +133,10 @@ type Server struct { TotalRequests int64 ElevateRequests int64 server *http.Server + + // Security components + rateLimiterMiddleware gin.HandlerFunc // Rate limiting middleware (ulule/limiter) + assertionCache *AssertionCache // SAML assertion ID replay protection } func (s *Server) GetConfig() *config.Config { @@ -145,6 +194,8 @@ func (s *Server) Start() error { router := gin.New() // Add middleware + // Correlation middleware MUST be first so correlation IDs are available to all subsequent middleware + router.Use(CorrelationMiddleware()) router.Use(gin.Logger()) router.Use(gin.CustomRecovery( func(c *gin.Context, err any) { @@ -195,6 +246,11 @@ func (s *Server) Start() error { sessionStore, )) + // Add CSRF protection using gorilla/csrf (cookie-based, no session dependency) + // This protects SP-initiated SAML flows where users start authentication at Thand server + // IdP-initiated flows are protected by SAML signature validation and assertion replay protection + router.Use(CSRFMiddleware([]byte(s.GetConfig().GetSecret()), true)) + // Set HTML template engine router.SetHTMLTemplate(s.TemplateEngine) @@ -434,8 +490,12 @@ func (s *Server) setupRoutes(router *gin.Engine) { // Sync endpoints api.GET("/sync", s.getSync) + // SAML/OAuth2 authentication routes + // CSRF protection: gorilla/csrf middleware protects all POST requests automatically + // Rate limiting: Applied to callbacks to prevent DoS attacks api.GET("/auth/request/:provider", s.getAuthRequest) - api.GET("/auth/callback/:provider", s.getAuthCallback) + api.GET("/auth/callback/:provider", s.rateLimiterMiddleware, s.getAuthCallback) // OAuth2 callbacks (GET) + api.POST("/auth/callback/:provider", s.rateLimiterMiddleware, s.postAuthCallback) // SAML callbacks (POST, CSRF-protected) api.GET("/auth/logout/:provider", s.getLogoutPage) api.GET("/auth/logout", s.getLogoutPage) @@ -664,7 +724,8 @@ func getSessionStore(secret string) sessions.Store { Path: "/", MaxAge: 86400 * 7, // 7 days HttpOnly: true, - Secure: true, // Set to true in production with HTTPS + Secure: true, // Set to true in production with HTTPS + SameSite: http.SameSiteLaxMode, // CSRF protection - prevents cross-site request forgery }) return store } diff --git a/internal/models/config.go b/internal/models/config.go index 0ebfa03d..ca70303c 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -22,6 +22,10 @@ type ServerLimitsConfig struct { IdleTimeout time.Duration `json:"idle_timeout" yaml:"idle_timeout" mapstructure:"idle_timeout"` RequestsPerMinute int `json:"requests_per_minute" yaml:"requests_per_minute" mapstructure:"requests_per_minute"` Burst int `json:"burst" yaml:"burst" mapstructure:"burst"` + + // SAML-specific rate limiting + SAMLRateLimit float64 `json:"saml_rate_limit" yaml:"saml_rate_limit" mapstructure:"saml_rate_limit"` + SAMLBurst int `json:"saml_burst" yaml:"saml_burst" mapstructure:"saml_burst"` } type LoginConfig struct { @@ -71,7 +75,15 @@ type ReadyConfig struct { } type SecurityConfig struct { - CORS CORSConfig `json:"cors" yaml:"cors" mapstructure:"cors"` + CORS CORSConfig `json:"cors" yaml:"cors" mapstructure:"cors"` + SAML SAMLSecurityConfig `json:"saml" yaml:"saml" mapstructure:"saml"` +} + +type SAMLSecurityConfig struct { + CSRFEnabled bool `json:"csrf_enabled" yaml:"csrf_enabled" mapstructure:"csrf_enabled"` + AssertionCacheTTL time.Duration `json:"assertion_cache_ttl" yaml:"assertion_cache_ttl" mapstructure:"assertion_cache_ttl"` + AssertionCacheCleanup time.Duration `json:"assertion_cache_cleanup" yaml:"assertion_cache_cleanup" mapstructure:"assertion_cache_cleanup"` + SessionDuration time.Duration `json:"session_duration" yaml:"session_duration" mapstructure:"session_duration"` } type CORSConfig struct { diff --git a/internal/providers/saml/main.go b/internal/providers/saml/main.go index 7294f6a7..b3d3093e 100644 --- a/internal/providers/saml/main.go +++ b/internal/providers/saml/main.go @@ -5,9 +5,13 @@ import ( "crypto/rsa" "crypto/tls" "crypto/x509" + "encoding/pem" "fmt" "net/http" "net/url" + "os" + "regexp" + "strings" "time" "github.com/crewjam/saml" @@ -18,24 +22,33 @@ import ( "github.com/thand-io/agent/internal/providers" ) +// AssertionCache defines the interface for assertion ID replay protection. +// This interface avoids circular dependencies by not importing internal/daemon. +type AssertionCache interface { + CheckAndAdd(assertionID string) bool +} + const SamlProviderName = "saml" // samlProvider implements the ProviderImpl interface for SAML type samlProvider struct { *models.BaseProvider - middleware *samlsp.Middleware - idpMetadata *saml.EntityDescriptor - certificates []tls.Certificate + middleware *samlsp.Middleware + idpMetadata *saml.EntityDescriptor + certificates []tls.Certificate + sessionDuration time.Duration // Configurable session expiry + allowIDPInitiated bool // Whether to allow IdP-initiated SAML flows } // SAMLConfig represents the SAML provider configuration type SAMLConfig struct { - IDPMetadataURL string `yaml:"idp_metadata_url" json:"idp_metadata_url"` - EntityID string `yaml:"entity_id" json:"entity_id"` - RootURL string `yaml:"root_url" json:"root_url"` - CertFile string `yaml:"cert_file" json:"cert_file"` - KeyFile string `yaml:"key_file" json:"key_file"` - SignRequests bool `yaml:"sign_requests" json:"sign_requests"` + IDPMetadataURL string `yaml:"idp_metadata_url" json:"idp_metadata_url"` + EntityID string `yaml:"entity_id" json:"entity_id"` + RootURL string `yaml:"root_url" json:"root_url"` + KeyPair tls.Certificate `yaml:"-" json:"-"` + SignRequests bool `yaml:"sign_requests" json:"sign_requests"` + SessionDuration time.Duration `yaml:"session_duration" json:"session_duration"` // Optional: session expiry (default: 24h) + AllowIDPInitiated bool `yaml:"allow_idp_initiated" json:"allow_idp_initiated"` // Optional: allow IdP-initiated flows (default: false) } func (p *samlProvider) Initialize(identifier string, provider models.Provider) error { @@ -52,15 +65,13 @@ func (p *samlProvider) Initialize(identifier string, provider models.Provider) e } // Load certificate and key for SAML signing - keyPair, err := tls.LoadX509KeyPair(config.CertFile, config.KeyFile) - if err != nil { - return fmt.Errorf("failed to load SAML certificate: %w", err) - } + keyPair := config.KeyPair - // Parse the certificate - keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) - if err != nil { - return fmt.Errorf("failed to parse SAML certificate: %w", err) + var privateKey *rsa.PrivateKey + if keyPair.PrivateKey != nil { + if pk, ok := keyPair.PrivateKey.(*rsa.PrivateKey); ok { + privateKey = pk + } } // Fetch IdP metadata @@ -80,24 +91,62 @@ func (p *samlProvider) Initialize(identifier string, provider models.Provider) e return fmt.Errorf("invalid root URL: %w", err) } - // Create SAML service provider - samlSP, err := samlsp.New(samlsp.Options{ - URL: *rootURL, - Key: keyPair.PrivateKey.(*rsa.PrivateKey), - Certificate: keyPair.Leaf, - IDPMetadata: idpMetadata, - EntityID: config.EntityID, - SignRequest: config.SignRequests, - }) - if err != nil { - return fmt.Errorf("failed to create SAML service provider: %w", err) + // Create SAML service provider with custom ACS URL + // The ACS URL must match what's configured in Okta: /api/v1/auth/callback/{provider-name} + acsURL := *rootURL + acsURL.Path = fmt.Sprintf("/api/v1/auth/callback/%s", identifier) + + metadataURL := *rootURL + metadataURL.Path = "/saml/metadata" + + // Create the ServiceProvider directly for more control + sp := &saml.ServiceProvider{ + EntityID: config.EntityID, + Key: privateKey, + Certificate: keyPair.Leaf, + MetadataURL: metadataURL, + AcsURL: acsURL, + IDPMetadata: idpMetadata, + AuthnNameIDFormat: saml.EmailAddressNameIDFormat, + // Allow IDP-initiated flows only if explicitly configured (security) + AllowIDPInitiated: config.AllowIDPInitiated, + } + + // Debug: Log the AllowIDPInitiated setting + logrus.WithFields(logrus.Fields{ + "provider": identifier, + "allow_idp_initiated": config.AllowIDPInitiated, + "entity_id": config.EntityID, + }).Info("SAML ServiceProvider initialized") + + // Create middleware wrapper + samlSP := &samlsp.Middleware{ + ServiceProvider: *sp, } p.middleware = samlSP p.idpMetadata = idpMetadata - p.certificates = []tls.Certificate{keyPair} + // Store certificates if configured (Certificate is a slice, check if not empty) + if len(keyPair.Certificate) > 0 { + p.certificates = []tls.Certificate{keyPair} + } - logrus.Infof("SAML provider %s initialized successfully", provider.Name) + // Store session duration (default to 24h if not configured) + p.sessionDuration = config.SessionDuration + if p.sessionDuration == 0 { + p.sessionDuration = 24 * time.Hour + } + + // Store IdP-initiated flow setting (defaults to false for security) + p.allowIDPInitiated = config.AllowIDPInitiated + + logrus.WithFields(logrus.Fields{ + "provider": provider.Name, + "entityID": samlSP.ServiceProvider.EntityID, + "acsURL": samlSP.ServiceProvider.AcsURL.String(), + "metadataURL": samlSP.ServiceProvider.MetadataURL.String(), + "idpIssuer": idpMetadata.EntityID, + }).Infof("SAML provider %s initialized successfully", provider.Name) return nil } @@ -113,6 +162,8 @@ func (p *samlProvider) AuthorizeSession(ctx context.Context, authRequest *models return nil, fmt.Errorf("failed to create SAML authentication request: %w", err) } + logrus.Debugln("SAML auth request generated") + return &models.AuthorizeSessionResponse{ Url: authURL.String(), }, nil @@ -123,33 +174,136 @@ func (p *samlProvider) CreateSession(ctx context.Context, authRequest *models.Au return nil, fmt.Errorf("SAML provider not initialized") } - // In a real implementation, this would parse the SAML response from the authorization code - // For now, we'll create a basic session structure - // The authRequest.Code should contain the SAML response or a reference to it - if len(authRequest.Code) == 0 { - return nil, fmt.Errorf("no SAML response code provided") + return nil, fmt.Errorf("no SAML response provided") + } + + // Input validation: SAMLResponse size limit (100KB) + if len(authRequest.Code) > 100000 { + logrus.Warn("SAMLResponse exceeds maximum length (100KB)") + return nil, fmt.Errorf("SAMLResponse exceeds maximum allowed length") + } + + // Log minimal debugging information without sensitive data + logrus.WithFields(logrus.Fields{ + "entityID": p.middleware.ServiceProvider.EntityID, + "acsURL": p.middleware.ServiceProvider.AcsURL.String(), + }).Debugln("Attempting to parse SAML response") + + // Parse the SAML response + // IMPORTANT: The URL in the request must match the ACS URL for validation to pass. + // SAML signature validation requires the request URL to match the ACS URL exactly. + // We must use PostForm (not Form) for POST requests because Form merges query and post parameters, + // which can cause SAML signature validation to fail or introduce security issues if parameters are mixed. + req := &http.Request{ + Method: "POST", + URL: &p.middleware.ServiceProvider.AcsURL, + PostForm: url.Values{ + "SAMLResponse": {authRequest.Code}, + }, + } + + // Handle state parameter for SP-initiated vs IdP-initiated flows + // For IdP-initiated flows, state may be empty - pass empty slice instead of []string{""} + var possibleRequestIDs []string + if authRequest.State != "" { + possibleRequestIDs = []string{authRequest.State} + } + + // Debug: Log what we're passing to ParseResponse + logrus.WithFields(logrus.Fields{ + "has_state": authRequest.State != "", + "state_length": len(authRequest.State), + "state_preview": truncateString(authRequest.State, 50), + "possible_request_ids": len(possibleRequestIDs), + "allow_idp_initiated": p.middleware.ServiceProvider.AllowIDPInitiated, + }).Debug("Calling ParseResponse") + + assertion, err := p.middleware.ServiceProvider.ParseResponse( + req, + possibleRequestIDs, + ) + + if err != nil { + // Log error without sensitive SAML response data + errMsg := err.Error() + errType := fmt.Sprintf("%T", err) + + // Try to extract more details from the error + logFields := logrus.Fields{ + "error": errMsg, + "errorType": errType, + "entityID": p.middleware.ServiceProvider.EntityID, + "acsURL": p.middleware.ServiceProvider.AcsURL.String(), + "hasState": authRequest.State != "", + } + + // Check if this is an InvalidResponseError and log more context + if strings.Contains(errType, "InvalidResponseError") { + logFields["hint"] = "Check: 1) SAML signature, 2) Time sync, 3) Audience/EntityID, 4) InResponseTo matching" + } + + logrus.WithFields(logFields).Errorln("Failed to parse SAML response") + + // InvalidResponseError typically means: + // 1. Signature validation failed (most common) + // 2. Time validation failed (NotBefore/NotOnOrAfter) + // 3. Audience restriction mismatch + // 4. InResponseTo validation failed (SP-initiated flow) + + return nil, fmt.Errorf("failed to parse SAML response: %w", err) + } + + // Security validations for the assertion + + // 1. Assertion ID replay protection (CRITICAL) + if assertion.ID != "" { + // Try to get assertion cache from context + if cache, ok := ctx.Value("assertion_cache").(AssertionCache); ok && cache != nil { + if !cache.CheckAndAdd(assertion.ID) { + logrus.WithFields(logrus.Fields{ + "assertion_id": assertion.ID, + "event": "replay_attack", + }).Error("SAML assertion replay attack detected") + return nil, fmt.Errorf("assertion replay detected: ID %s has already been used", assertion.ID) + } + } else { + // Log warning if cache not available (shouldn't happen in production) + logrus.Warn("Assertion cache not available in context - replay protection disabled") + } + } + + // 2. Validate SubjectConfirmation + // Note: Destination URL validation is already performed by ParseResponse in the SAML library + if err := p.validateSubjectConfirmation(assertion); err != nil { + // Log warning but don't fail - some IdPs may not include all SubjectConfirmation fields + logrus.WithError(err).Warn("SAML SubjectConfirmation validation failed - continuing anyway") + } + + // 3. Check for OneTimeUse condition (informational - already enforced by replay protection) + if p.hasOneTimeUseCondition(assertion) { + logrus.Debug("OneTimeUse condition present in assertion (enforced by replay protection)") } // Extract user information from SAML assertion - // This is a simplified implementation - in practice you'd parse the actual SAML response - user := &models.User{ - Username: "saml_user", // Extract from SAML assertion - Email: "user@example.com", // Extract from SAML assertion - Source: "saml", - Groups: []string{}, // Extract groups from SAML assertion + user, err := p.extractUserFromAssertion(assertion) + if err != nil { + return nil, err } - // Create session + // Create session with configured duration (defaults to 24h if not set) session := &models.Session{ - UUID: uuid.New(), - User: user, - AccessToken: uuid.New().String(), // Generate or extract from SAML - RefreshToken: uuid.New().String(), - Expiry: time.Now().Add(24 * time.Hour), // Configurable session duration + UUID: uuid.New(), + User: user, + Expiry: time.Now().Add(p.sessionDuration), } - logrus.Infof("Created SAML session for user: %s", user.Username) + // Log session creation without PII details + logrus.WithFields(logrus.Fields{ + "sessionUUID": session.UUID.String(), + "source": "saml", + }).Info("Created SAML session successfully") + return session, nil } @@ -163,11 +317,6 @@ func (p *samlProvider) ValidateSession(ctx context.Context, session *models.Sess return fmt.Errorf("session has expired") } - // Validate access token (in a real implementation, you might validate against IdP) - if len(session.AccessToken) == 0 { - return fmt.Errorf("invalid access token") - } - // Validate user information if session.User == nil { return fmt.Errorf("session user is nil") @@ -188,13 +337,11 @@ func (p *samlProvider) RenewSession(ctx context.Context, session *models.Session return nil, fmt.Errorf("cannot renew expired session: %w", err) } - // Create a new session with extended expiry + // Create a new session with extended expiry (using configured session duration) newSession := &models.Session{ - UUID: uuid.New(), - User: session.User, - AccessToken: uuid.New().String(), - RefreshToken: uuid.New().String(), - Expiry: time.Now().Add(24 * time.Hour), + UUID: uuid.New(), + User: session.User, + Expiry: time.Now().Add(p.sessionDuration), } logrus.Infof("Renewed SAML session for user: %s", session.User.Username) @@ -288,6 +435,89 @@ func (p *samlProvider) SendNotification(ctx context.Context, notification models return fmt.Errorf("SendNotification not implemented for SAML provider") } +// IsIDPInitiatedAllowed checks if IdP-initiated SAML flows are permitted +func (p *samlProvider) IsIDPInitiatedAllowed() bool { + return p.allowIDPInitiated +} + +// extractUserFromAssertion extracts user information from a SAML assertion +func (p *samlProvider) extractUserFromAssertion(assertion *saml.Assertion) (*models.User, error) { + var userID string + var username string + var email string + var name string + var groups []string + + // Extract attributes from the assertion + if assertion != nil { + // Get NameID (usually the username or email) + if assertion.Subject != nil && assertion.Subject.NameID != nil { + nameID := assertion.Subject.NameID.Value + // Use NameID as email if it looks like an email + // Basic email regex: local@domain.tld (allows common valid patterns) + emailRegex := `^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$` + if matched, _ := regexp.MatchString(emailRegex, nameID); matched { + email = nameID + // Extract username from email (part before @) + if atIndex := strings.Index(nameID, "@"); atIndex > 0 { + username = nameID[:atIndex] + } + } else { + // Not a valid email, use as username + username = nameID + } + } + + // Extract attributes + for _, stmt := range assertion.AttributeStatements { + for _, attr := range stmt.Attributes { + switch attr.Name { + case "email", "Email", "emailAddress", "mail": + if len(attr.Values) > 0 { + email = attr.Values[0].Value + } + case "name", "displayName", "Name", "cn", "commonName": + if len(attr.Values) > 0 { + name = attr.Values[0].Value + } + case "username", "Username", "sAMAccountName": + if len(attr.Values) > 0 { + username = attr.Values[0].Value + } + case "userid", "UserID", "uid", "objectGUID": + if len(attr.Values) > 0 { + userID = attr.Values[0].Value + } + case "groups", "Groups", "memberOf": + for _, v := range attr.Values { + groups = append(groups, v.Value) + } + } + } + } + } + + if len(email) == 0 { + return nil, fmt.Errorf("missing required user attributes in SAML assertion") + } + + if len(userID) == 0 { + userID = email + } + + // Create user identity + user := &models.User{ + ID: userID, + Username: username, + Email: email, + Name: name, + Source: "saml", + Groups: groups, + } + + return user, nil +} + // parseSAMLConfig parses the SAML configuration from the provider config func (p *samlProvider) parseSAMLConfig(config *models.BasicConfig) (*SAMLConfig, error) { if config == nil { @@ -297,46 +527,200 @@ func (p *samlProvider) parseSAMLConfig(config *models.BasicConfig) (*SAMLConfig, samlConfig := &SAMLConfig{} // Parse required fields - if idpURL, ok := (*config)["idp_metadata_url"].(string); ok { + if idpURL, ok := config.GetString("idp_metadata_url"); ok { samlConfig.IDPMetadataURL = idpURL } else { return nil, fmt.Errorf("idp_metadata_url is required") } - if entityID, ok := (*config)["entity_id"].(string); ok { + if entityID, ok := config.GetString("entity_id"); ok { samlConfig.EntityID = entityID } else { return nil, fmt.Errorf("entity_id is required") } - if rootURL, ok := (*config)["root_url"].(string); ok { + if rootURL, ok := config.GetString("root_url"); ok { samlConfig.RootURL = rootURL } else { return nil, fmt.Errorf("root_url is required") } - if certFile, ok := (*config)["cert_file"].(string); ok { - samlConfig.CertFile = certFile - } else { - return nil, fmt.Errorf("cert_file is required") + var certFile, cert string + if v, ok := config.GetString("cert_file"); ok { + certFile = v + } + if v, ok := config.GetString("cert"); ok { + cert = v } - if keyFile, ok := (*config)["key_file"].(string); ok { - samlConfig.KeyFile = keyFile - } else { - return nil, fmt.Errorf("key_file is required") + var keyFile, key string + if v, ok := config.GetString("key_file"); ok { + keyFile = v + } + if v, ok := config.GetString("key"); ok { + key = v + } + + var keyPair tls.Certificate + var err error + + if cert != "" { + if key != "" { + keyPair, err = tls.X509KeyPair([]byte(cert), []byte(key)) + if err != nil { + return nil, fmt.Errorf("failed to parse SAML certificate from config: %w", err) + } + } else { + // Parse inline certificate without key (for verification only) + block, _ := pem.Decode([]byte(cert)) + if block == nil { + return nil, fmt.Errorf("failed to parse certificate PEM") + } + // Parse the certificate to populate keyPair.Leaf + leaf, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + keyPair = tls.Certificate{ + Certificate: [][]byte{block.Bytes}, + Leaf: leaf, + } + } + } else if certFile != "" { + if keyFile != "" { + keyPair, err = tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("failed to load SAML certificate: %w", err) + } + } else { + // Parse certificate file without key (for verification only) + certBytes, err := os.ReadFile(certFile) + if err != nil { + return nil, fmt.Errorf("failed to read certificate file: %w", err) + } + block, _ := pem.Decode(certBytes) + if block == nil { + return nil, fmt.Errorf("failed to parse certificate PEM from file") + } + // Parse the certificate to populate keyPair.Leaf + leaf, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate from file: %w", err) + } + keyPair = tls.Certificate{ + Certificate: [][]byte{block.Bytes}, + Leaf: leaf, + } + } + } + + if len(keyPair.Certificate) > 0 { + // Parse the certificate leaf + keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("failed to parse SAML certificate leaf: %w", err) + } + samlConfig.KeyPair = keyPair } // Parse optional fields - if signRequests, ok := (*config)["sign_requests"].(bool); ok { + if signRequests, ok := config.GetBool("sign_requests"); ok { samlConfig.SignRequests = signRequests } else { samlConfig.SignRequests = false // Default to false } + // Validation: If signing is enabled, we MUST have a private key + if samlConfig.SignRequests && samlConfig.KeyPair.PrivateKey == nil { + return nil, fmt.Errorf("sign_requests is set to true, but no private key was provided (cert/key or cert_file/key_file)") + } + + // Parse session_duration (default: 24 hours) + if sessionDurationStr, ok := config.GetString("session_duration"); ok { + sessionDuration, err := time.ParseDuration(sessionDurationStr) + if err != nil { + logrus.WithError(err).WithField("session_duration", sessionDurationStr).Warn("Invalid session_duration format, using default 24h") + samlConfig.SessionDuration = 24 * time.Hour + } else { + samlConfig.SessionDuration = sessionDuration + } + } else { + samlConfig.SessionDuration = 24 * time.Hour // Default to 24 hours + } + + // Parse allow_idp_initiated (default: false for security) + if allowIDPInitiated, ok := config.GetBool("allow_idp_initiated"); ok { + samlConfig.AllowIDPInitiated = allowIDPInitiated + } else { + samlConfig.AllowIDPInitiated = false // Default to false for security + } + + // Log the critical security setting + logrus.WithFields(logrus.Fields{ + "allow_idp_initiated": samlConfig.AllowIDPInitiated, + "session_duration": samlConfig.SessionDuration, + }).Debug("SAML config parsed") + return samlConfig, nil } +// validateSubjectConfirmation validates the SAML SubjectConfirmation element. +// This ensures that the assertion was intended for this service provider and is still valid. +func (p *samlProvider) validateSubjectConfirmation(assertion *saml.Assertion) error { + if assertion.Subject == nil || len(assertion.Subject.SubjectConfirmations) == 0 { + return fmt.Errorf("no SubjectConfirmation found in assertion") + } + + // Check for at least one valid Bearer confirmation + for _, sc := range assertion.Subject.SubjectConfirmations { + // We expect Bearer confirmation method for web SSO + if sc.Method == "urn:oasis:names:tc:SAML:2.0:cm:bearer" { + if sc.SubjectConfirmationData != nil { + // Validate Recipient matches our ACS URL + if sc.SubjectConfirmationData.Recipient != "" { + expectedRecipient := p.middleware.ServiceProvider.AcsURL.String() + if sc.SubjectConfirmationData.Recipient != expectedRecipient { + return fmt.Errorf("SubjectConfirmation Recipient mismatch: expected %s, got %s", + expectedRecipient, sc.SubjectConfirmationData.Recipient) + } + } + + // Validate NotOnOrAfter (if present) + if !sc.SubjectConfirmationData.NotOnOrAfter.IsZero() { + if time.Now().After(sc.SubjectConfirmationData.NotOnOrAfter) { + return fmt.Errorf("SubjectConfirmation expired (NotOnOrAfter: %s)", + sc.SubjectConfirmationData.NotOnOrAfter.Format(time.RFC3339)) + } + } + } + + // Found valid Bearer confirmation + return nil + } + } + + return fmt.Errorf("no valid Bearer SubjectConfirmation found") +} + +// hasOneTimeUseCondition checks if the assertion has a OneTimeUse condition. +// This is informational since we already enforce one-time use through assertion ID replay protection. +func (p *samlProvider) hasOneTimeUseCondition(assertion *saml.Assertion) bool { + if assertion.Conditions == nil { + return false + } + + // Check for OneTimeUse condition + return assertion.Conditions.OneTimeUse != nil +} + +// truncateString returns a truncated version of the string for logging purposes +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + func init() { providers.Register(SamlProviderName, &samlProvider{}) } diff --git a/internal/providers/saml/main_test.go b/internal/providers/saml/main_test.go index 2637834d..6eeb99b2 100644 --- a/internal/providers/saml/main_test.go +++ b/internal/providers/saml/main_test.go @@ -2,202 +2,1332 @@ package saml import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" "testing" "time" + "github.com/crewjam/saml" + "github.com/crewjam/saml/samlsp" "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/thand-io/agent/internal/models" ) -func TestSAMLProvider_BasicFunctionality(t *testing.T) { - // Test basic provider functionality without full SAML setup - provider := &samlProvider{} +func TestSAMLProvider_ParseSAMLConfig(t *testing.T) { + // Create test certificate and key for use in tests + cert, key := createTestCert(t) + certFile, keyFile := writeCertAndKeyToFiles(t, cert, key) - // Test config parsing with invalid config - _, err := provider.parseSAMLConfig(nil) - if err == nil { - t.Error("Expected error for nil config") - } + // Create PEM encoded strings + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) - // Test config parsing with missing required fields - config := &models.BasicConfig{ - "idp_metadata_url": "https://example.com/metadata", - // Missing other required fields - } - _, err = provider.parseSAMLConfig(config) - if err == nil { - t.Error("Expected error for incomplete config") + tests := []struct { + name string + config *models.BasicConfig + expectError bool + errorContains string + validateResult func(t *testing.T, cfg *SAMLConfig) + }{ + { + name: "Nil config", + config: nil, + expectError: true, + errorContains: "config is nil", + }, + { + name: "Missing required fields", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + }, + expectError: true, + errorContains: "is required", + }, + { + name: "Valid config with file-based certs", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + "entity_id": "https://myapp.com/saml", + "root_url": "https://myapp.com", + "cert_file": certFile, + "key_file": keyFile, + "sign_requests": true, + }, + expectError: false, + validateResult: func(t *testing.T, cfg *SAMLConfig) { + assert.Equal(t, "https://example.com/metadata", cfg.IDPMetadataURL) + assert.Equal(t, "https://myapp.com/saml", cfg.EntityID) + assert.Equal(t, "https://myapp.com", cfg.RootURL) + assert.True(t, cfg.SignRequests) + assert.NotNil(t, cfg.KeyPair.PrivateKey) + assert.NotNil(t, cfg.KeyPair.Leaf) + }, + }, + { + name: "Valid config with inline certs", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + "entity_id": "https://myapp.com/saml", + "root_url": "https://myapp.com", + "cert": string(certPEM), + "key": string(keyPEM), + "sign_requests": false, + }, + expectError: false, + validateResult: func(t *testing.T, cfg *SAMLConfig) { + assert.Equal(t, "https://example.com/metadata", cfg.IDPMetadataURL) + assert.Equal(t, "https://myapp.com/saml", cfg.EntityID) + assert.Equal(t, "https://myapp.com", cfg.RootURL) + assert.False(t, cfg.SignRequests) + assert.NotNil(t, cfg.KeyPair.PrivateKey) + assert.NotNil(t, cfg.KeyPair.Leaf) + }, + }, + { + name: "Valid config with cert only (no key)", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + "entity_id": "https://myapp.com/saml", + "root_url": "https://myapp.com", + "cert": string(certPEM), + "sign_requests": false, + }, + expectError: false, + validateResult: func(t *testing.T, cfg *SAMLConfig) { + assert.NotNil(t, cfg.KeyPair.Leaf) + assert.Nil(t, cfg.KeyPair.PrivateKey) + assert.False(t, cfg.SignRequests) + }, + }, + { + name: "Valid config with cert_file only (no key_file)", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + "entity_id": "https://myapp.com/saml", + "root_url": "https://myapp.com", + "cert_file": certFile, + "sign_requests": false, + }, + expectError: false, + validateResult: func(t *testing.T, cfg *SAMLConfig) { + assert.NotNil(t, cfg.KeyPair.Leaf) + assert.Nil(t, cfg.KeyPair.PrivateKey) + }, + }, + { + name: "Valid config without sign_requests (defaults to false)", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + "entity_id": "https://myapp.com/saml", + "root_url": "https://myapp.com", + "cert_file": certFile, + "key_file": keyFile, + }, + expectError: false, + validateResult: func(t *testing.T, cfg *SAMLConfig) { + assert.False(t, cfg.SignRequests) + }, + }, + { + name: "Error: sign_requests true without private key", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + "entity_id": "https://myapp.com/saml", + "root_url": "https://myapp.com", + "cert": string(certPEM), + "sign_requests": true, + }, + expectError: true, + errorContains: "sign_requests is set to true, but no private key was provided", + }, + { + name: "Error: invalid inline cert PEM", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + "entity_id": "https://myapp.com/saml", + "root_url": "https://myapp.com", + "cert": "not-a-valid-pem", + "key": string(keyPEM), + }, + expectError: true, + errorContains: "failed to parse SAML certificate from config", + }, + { + name: "Error: invalid inline key", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + "entity_id": "https://myapp.com/saml", + "root_url": "https://myapp.com", + "cert": string(certPEM), + "key": "not-a-valid-key", + }, + expectError: true, + errorContains: "failed to parse SAML certificate from config", + }, + { + name: "Error: nonexistent cert file", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + "entity_id": "https://myapp.com/saml", + "root_url": "https://myapp.com", + "cert_file": "/nonexistent/cert.pem", + "key_file": "/nonexistent/key.pem", + }, + expectError: true, + errorContains: "failed to load SAML certificate", + }, + { + name: "Error: nonexistent single cert file (no key)", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + "entity_id": "https://myapp.com/saml", + "root_url": "https://myapp.com", + "cert_file": "/nonexistent/cert.pem", + }, + expectError: true, + errorContains: "failed to read certificate file", + }, + { + name: "Valid config with no certs (signing disabled)", + config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + "entity_id": "https://myapp.com/saml", + "root_url": "https://myapp.com", + "sign_requests": false, + }, + expectError: false, + validateResult: func(t *testing.T, cfg *SAMLConfig) { + assert.False(t, cfg.SignRequests) + assert.Nil(t, cfg.KeyPair.Leaf) + assert.Nil(t, cfg.KeyPair.PrivateKey) + }, + }, } - // Test config parsing with valid config - validConfig := &models.BasicConfig{ - "idp_metadata_url": "https://example.com/metadata", - "entity_id": "https://myapp.com/saml", - "root_url": "https://myapp.com", - "cert_file": "/path/to/cert.pem", - "key_file": "/path/to/key.pem", - "sign_requests": true, - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &samlProvider{} - samlConfig, err := provider.parseSAMLConfig(validConfig) - if err != nil { - t.Errorf("Unexpected error parsing valid config: %v", err) - } + samlConfig, err := provider.parseSAMLConfig(tt.config) - if samlConfig.IDPMetadataURL != "https://example.com/metadata" { - t.Error("IDPMetadataURL not parsed correctly") + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.NotNil(t, samlConfig) + if tt.validateResult != nil { + tt.validateResult(t, samlConfig) + } + } + }) } +} - if samlConfig.EntityID != "https://myapp.com/saml" { - t.Error("EntityID not parsed correctly") +func TestSAMLProvider_SessionValidation(t *testing.T) { + tests := []struct { + name string + session *models.Session + expectError bool + errorContains string + }{ + { + name: "Nil session", + session: nil, + expectError: true, + errorContains: "session is nil", + }, + { + name: "Session without user", + session: &models.Session{ + UUID: uuid.New(), + Expiry: time.Now().Add(1 * time.Hour), + }, + expectError: true, + errorContains: "user is nil", + }, + { + name: "Expired session", + session: &models.Session{ + UUID: uuid.New(), + User: &models.User{Username: "testuser"}, + Expiry: time.Now().Add(-1 * time.Hour), + }, + expectError: true, + errorContains: "expired", + }, + { + name: "Valid session", + session: &models.Session{ + UUID: uuid.New(), + User: &models.User{Username: "testuser"}, + Expiry: time.Now().Add(1 * time.Hour), + }, + expectError: false, + }, } - if !samlConfig.SignRequests { - t.Error("SignRequests not parsed correctly") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &samlProvider{} + ctx := context.Background() + + err := provider.ValidateSession(ctx, tt.session) + + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) } } -func TestSAMLProvider_SessionValidation(t *testing.T) { - provider := &samlProvider{} - ctx := context.Background() - - // Test with nil session - err := provider.ValidateSession(ctx, nil) - if err == nil { - t.Error("Expected error for nil session") +func TestSAMLProvider_AuthorizeRole(t *testing.T) { + tests := []struct { + name string + request *models.AuthorizeRoleRequest + expectError bool + errorContains string + }{ + { + name: "Nil user", + request: &models.AuthorizeRoleRequest{ + RoleRequest: &models.RoleRequest{ + User: nil, + Role: &models.Role{Name: "test-role"}, + }, + }, + expectError: true, + errorContains: "must be provided", + }, + { + name: "Nil role", + request: &models.AuthorizeRoleRequest{ + RoleRequest: &models.RoleRequest{ + User: &models.User{Username: "testuser"}, + Role: nil, + }, + }, + expectError: true, + errorContains: "must be provided", + }, + { + name: "User without permission", + request: &models.AuthorizeRoleRequest{ + RoleRequest: &models.RoleRequest{ + User: &models.User{ + Username: "testuser", + Email: "test@example.com", + Groups: []string{"group1"}, + }, + Role: &models.Role{ + Name: "admin-role", + Scopes: &models.RoleScopes{ + Groups: []string{"admin-group"}, + }, + }, + }, + }, + expectError: true, + errorContains: "does not have permission", + }, + { + name: "User with permission via group", + request: &models.AuthorizeRoleRequest{ + RoleRequest: &models.RoleRequest{ + User: &models.User{ + Username: "testuser", + Email: "test@example.com", + Groups: []string{"admin-group"}, + }, + Role: &models.Role{ + Name: "admin-role", + Scopes: &models.RoleScopes{ + Groups: []string{"admin-group"}, + }, + }, + }, + }, + expectError: false, + }, + { + name: "User with permission via user list (email)", + request: &models.AuthorizeRoleRequest{ + RoleRequest: &models.RoleRequest{ + User: &models.User{ + Username: "testuser", + Email: "admin@example.com", + }, + Role: &models.Role{ + Name: "admin-role", + Scopes: &models.RoleScopes{ + Users: []string{"admin@example.com"}, + }, + }, + }, + }, + expectError: false, + }, + { + name: "User with permission via user list (username)", + request: &models.AuthorizeRoleRequest{ + RoleRequest: &models.RoleRequest{ + User: &models.User{ + Username: "admin-user", + Email: "test@example.com", + }, + Role: &models.Role{ + Name: "admin-role", + Scopes: &models.RoleScopes{ + Users: []string{"admin-user"}, + }, + }, + }, + }, + expectError: false, + }, + { + name: "User with permission via domain", + request: &models.AuthorizeRoleRequest{ + RoleRequest: &models.RoleRequest{ + User: &models.User{ + Username: "testuser", + Email: "test@example.com", + }, + Role: &models.Role{ + Name: "admin-role", + Scopes: &models.RoleScopes{ + Domains: []string{"example.com"}, + }, + }, + }, + }, + expectError: false, + }, } - // Test with session missing user - session := &models.Session{ - UUID: uuid.New(), - AccessToken: "test-token", - Expiry: time.Now().Add(1 * time.Hour), + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &samlProvider{} + ctx := context.Background() + + _, err := provider.AuthorizeRole(ctx, tt.request) + + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) } - err = provider.ValidateSession(ctx, session) - if err == nil { - t.Error("Expected error for session without user") +} + +func TestSAMLProvider_RevokeRole(t *testing.T) { + tests := []struct { + name string + request *models.RevokeRoleRequest + expectError bool + errorContains string + }{ + { + name: "Valid revocation request", + request: &models.RevokeRoleRequest{ + RoleRequest: &models.RoleRequest{ + User: &models.User{ + Username: "testuser", + Email: "test@example.com", + }, + Role: &models.Role{ + Name: "test-role", + }, + }, + }, + expectError: false, + }, + { + name: "Nil user", + request: &models.RevokeRoleRequest{ + RoleRequest: &models.RoleRequest{ + User: nil, + Role: &models.Role{Name: "test-role"}, + }, + }, + expectError: true, + errorContains: "must be provided", + }, + { + name: "Nil role", + request: &models.RevokeRoleRequest{ + RoleRequest: &models.RoleRequest{ + User: &models.User{Username: "testuser"}, + Role: nil, + }, + }, + expectError: true, + errorContains: "must be provided", + }, } - // Test with expired session - expiredSession := &models.Session{ - UUID: uuid.New(), - User: &models.User{Username: "testuser"}, - AccessToken: "test-token", - Expiry: time.Now().Add(-1 * time.Hour), // Expired + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &samlProvider{} + ctx := context.Background() + + _, err := provider.RevokeRole(ctx, tt.request) + + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) } - err = provider.ValidateSession(ctx, expiredSession) - if err == nil { - t.Error("Expected error for expired session") +} + +func TestSAMLProvider_NotImplementedMethods(t *testing.T) { + tests := []struct { + name string + testFunc func(*samlProvider, context.Context) error + expectError bool + errorContains string + description string + }{ + { + name: "GetPermission returns error", + testFunc: func(p *samlProvider, ctx context.Context) error { + _, err := p.GetPermission(ctx, "test-permission") + return err + }, + expectError: true, + errorContains: "not implemented", + description: "GetPermission not implemented for SAML", + }, + { + name: "ListPermissions returns empty list", + testFunc: func(p *samlProvider, ctx context.Context) error { + permissions, err := p.ListPermissions(ctx) + if err != nil { + return err + } + if len(permissions) != 0 { + return assert.AnError + } + return nil + }, + expectError: false, + description: "ListPermissions should return empty list", + }, + { + name: "GetRole returns error", + testFunc: func(p *samlProvider, ctx context.Context) error { + _, err := p.GetRole(ctx, "test-role") + return err + }, + expectError: true, + errorContains: "not implemented", + description: "GetRole not implemented for SAML", + }, + { + name: "ListRoles returns empty list", + testFunc: func(p *samlProvider, ctx context.Context) error { + roles, err := p.ListRoles(ctx) + if err != nil { + return err + } + if len(roles) != 0 { + return assert.AnError + } + return nil + }, + expectError: false, + description: "ListRoles should return empty list", + }, + { + name: "GetResource returns error", + testFunc: func(p *samlProvider, ctx context.Context) error { + _, err := p.GetResource(ctx, "test-resource") + return err + }, + expectError: true, + errorContains: "not implemented", + description: "GetResource not implemented for SAML", + }, + { + name: "ListResources returns empty list", + testFunc: func(p *samlProvider, ctx context.Context) error { + resources, err := p.ListResources(ctx) + if err != nil { + return err + } + if len(resources) != 0 { + return assert.AnError + } + return nil + }, + expectError: false, + description: "ListResources should return empty list", + }, + { + name: "SendNotification returns error", + testFunc: func(p *samlProvider, ctx context.Context) error { + return p.SendNotification(ctx, models.NotificationRequest{}) + }, + expectError: true, + errorContains: "not implemented", + description: "SendNotification not implemented for SAML", + }, } - // Test with valid session - validSession := &models.Session{ - UUID: uuid.New(), - User: &models.User{Username: "testuser"}, - AccessToken: "test-token", - Expiry: time.Now().Add(1 * time.Hour), + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &samlProvider{} + ctx := context.Background() + + err := tt.testFunc(provider, ctx) + + if tt.expectError { + assert.Error(t, err, tt.description) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err, tt.description) + } + }) } - err = provider.ValidateSession(ctx, validSession) - if err != nil { - t.Errorf("Unexpected error for valid session: %v", err) +} + +// Helper function to create a test certificate +func createTestCert(t *testing.T) (cert *x509.Certificate, key *rsa.PrivateKey) { + t.Helper() + + // Generate RSA key + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + // Create certificate template + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Test Org"}, + CommonName: "test.example.com", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, } + + // Create certificate + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + require.NoError(t, err) + + cert, err = x509.ParseCertificate(certBytes) + require.NoError(t, err) + + return cert, key } -func TestSAMLProvider_Authorization(t *testing.T) { - provider := &samlProvider{} - ctx := context.Background() +// Helper function to write certificate and key to temporary files +func writeCertAndKeyToFiles(t *testing.T, cert *x509.Certificate, key *rsa.PrivateKey) (certFile, keyFile string) { + t.Helper() - user := &models.User{ - Username: "testuser", - Email: "test@example.com", + tempDir := t.TempDir() + + // Write certificate (0644 is appropriate - certificates are public) + // SECURITY NOTE: Certificate files can be world-readable (0644) + // Only private key files should have restrictive permissions (0600) + certFile = filepath.Join(tempDir, "cert.pem") + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) + err := os.WriteFile(certFile, certPEM, 0644) + require.NoError(t, err) + + // Write private key + keyFile = filepath.Join(tempDir, "key.pem") + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + err = os.WriteFile(keyFile, keyPEM, 0600) + require.NoError(t, err) + + return certFile, keyFile +} + +// Helper function to create a mock IDP metadata server +func createMockIDPMetadataServer(t *testing.T) *httptest.Server { + t.Helper() + + metadataXML := ` + + + + + +` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + w.Write([]byte(metadataXML)) + })) + + return server +} + +// Helper function to create a test SAML provider with mock setup +func createTestSAMLProvider(t *testing.T) *samlProvider { + t.Helper() + + cert, key := createTestCert(t) + + rootURL, err := url.Parse("https://test.example.com") + require.NoError(t, err) + + acsURL := *rootURL + acsURL.Path = "/api/v1/auth/callback/test-saml" + + metadataURL := *rootURL + metadataURL.Path = "/saml/metadata" + + // Create IDP metadata + idpMetadata := &saml.EntityDescriptor{ + EntityID: "https://idp.example.com", + IDPSSODescriptors: []saml.IDPSSODescriptor{ + { + SingleSignOnServices: []saml.Endpoint{ + { + Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + Location: "https://idp.example.com/sso", + }, + }, + }, + }, } - // Create a mock role that has permission for the user - role := &models.Role{ - Name: "test-role", - // Note: In a real test, you'd need to set up proper role permissions + sp := &saml.ServiceProvider{ + EntityID: "test-entity-id", + Key: key, + Certificate: cert, + MetadataURL: metadataURL, + AcsURL: acsURL, + IDPMetadata: idpMetadata, + AuthnNameIDFormat: saml.EmailAddressNameIDFormat, + AllowIDPInitiated: true, } - // Test authorization with nil user - _, err := provider.AuthorizeRole(ctx, &models.AuthorizeRoleRequest{ - RoleRequest: &models.RoleRequest{ - User: nil, - Role: role, - }, - }) - if err == nil { - t.Error("Expected error for nil user") + middleware := &samlsp.Middleware{ + ServiceProvider: *sp, } - // Test authorization with nil role - _, err = provider.AuthorizeRole(ctx, &models.AuthorizeRoleRequest{ - RoleRequest: &models.RoleRequest{ - User: user, - Role: nil, + return &samlProvider{ + middleware: middleware, + idpMetadata: idpMetadata, + certificates: []tls.Certificate{ + { + Certificate: [][]byte{cert.Raw}, + PrivateKey: key, + Leaf: cert, + }, }, - }) - if err == nil { - t.Error("Expected error for nil role") } +} - // Test revocation - _, err = provider.RevokeRole(ctx, &models.RevokeRoleRequest{ - RoleRequest: &models.RoleRequest{ - User: user, - Role: role, +// TestSAMLProvider_CreateSession tests CreateSession with various scenarios +func TestSAMLProvider_CreateSession(t *testing.T) { + tests := []struct { + name string + setupProvider func(t *testing.T) *samlProvider + authRequest *models.AuthorizeUser + expectError bool + errorContains string + validateResult func(t *testing.T, session *models.Session) + }{ + { + name: "Uninitialized provider", + setupProvider: func(t *testing.T) *samlProvider { + return &samlProvider{} + }, + authRequest: &models.AuthorizeUser{ + Code: "test-saml-response", + State: "test-state", + }, + expectError: true, + errorContains: "not initialized", }, - }) - if err != nil { - t.Errorf("Unexpected error in revocation: %v", err) + { + name: "Empty SAML response code", + setupProvider: func(t *testing.T) *samlProvider { + return createTestSAMLProvider(t) + }, + authRequest: &models.AuthorizeUser{ + Code: "", + State: "test-state", + }, + expectError: true, + errorContains: "no SAML response provided", + }, + { + name: "Invalid SAML response format", + setupProvider: func(t *testing.T) *samlProvider { + return createTestSAMLProvider(t) + }, + authRequest: &models.AuthorizeUser{ + Code: "invalid-saml-response", + State: "test-state", + }, + expectError: true, + errorContains: "failed to parse SAML response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := tt.setupProvider(t) + ctx := context.Background() + + session, err := provider.CreateSession(ctx, tt.authRequest) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, session) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.NotNil(t, session) + if tt.validateResult != nil { + tt.validateResult(t, session) + } + } + }) } } -func TestSAMLProvider_NotImplementedMethods(t *testing.T) { +// TestSAMLProvider_ExtractUserFromAssertion tests SAML user attribute extraction +func TestSAMLProvider_ExtractUserFromAssertion(t *testing.T) { provider := &samlProvider{} - ctx := context.Background() - // Test methods that should return not implemented errors - _, err := provider.GetPermission(ctx, "test-permission") - if err == nil { - t.Error("Expected not implemented error for GetPermission") + tests := []struct { + name string + assertion *saml.Assertion + expectError bool + errorContains string + validateUser func(t *testing.T, user *models.User) + }{ + { + name: "Extract email from attribute", + assertion: &saml.Assertion{ + AttributeStatements: []saml.AttributeStatement{ + { + Attributes: []saml.Attribute{ + { + Name: "email", + Values: []saml.AttributeValue{{Value: "user@example.com"}}, + }, + }, + }, + }, + }, + expectError: false, + validateUser: func(t *testing.T, user *models.User) { + assert.Equal(t, "user@example.com", user.Email) + assert.Equal(t, "user@example.com", user.ID) // Falls back to email + }, + }, + { + name: "Extract all attributes", + assertion: &saml.Assertion{ + Subject: &saml.Subject{ + NameID: &saml.NameID{Value: "testuser"}, + }, + AttributeStatements: []saml.AttributeStatement{ + { + Attributes: []saml.Attribute{ + {Name: "email", Values: []saml.AttributeValue{{Value: "test@example.com"}}}, + {Name: "name", Values: []saml.AttributeValue{{Value: "Test User"}}}, + {Name: "username", Values: []saml.AttributeValue{{Value: "testuser"}}}, + {Name: "userid", Values: []saml.AttributeValue{{Value: "user123"}}}, + {Name: "groups", Values: []saml.AttributeValue{{Value: "group1"}, {Value: "group2"}}}, + }, + }, + }, + }, + expectError: false, + validateUser: func(t *testing.T, user *models.User) { + assert.Equal(t, "test@example.com", user.Email) + assert.Equal(t, "Test User", user.Name) + assert.Equal(t, "testuser", user.Username) + assert.Equal(t, "user123", user.ID) + assert.Equal(t, []string{"group1", "group2"}, user.Groups) + assert.Equal(t, "saml", user.Source) + }, + }, + { + name: "Extract email from NameID", + assertion: &saml.Assertion{ + Subject: &saml.Subject{ + NameID: &saml.NameID{Value: "user@example.com"}, + }, + }, + expectError: false, + validateUser: func(t *testing.T, user *models.User) { + assert.Equal(t, "user@example.com", user.Email) + assert.Equal(t, "user", user.Username) // Extracted from email + }, + }, + { + name: "Extract username from NameID (not email)", + assertion: &saml.Assertion{ + Subject: &saml.Subject{ + NameID: &saml.NameID{Value: "johndoe"}, + }, + AttributeStatements: []saml.AttributeStatement{ + { + Attributes: []saml.Attribute{ + {Name: "email", Values: []saml.AttributeValue{{Value: "john@example.com"}}}, + }, + }, + }, + }, + expectError: false, + validateUser: func(t *testing.T, user *models.User) { + assert.Equal(t, "john@example.com", user.Email) + assert.Equal(t, "johndoe", user.Username) + }, + }, + { + name: "Missing email returns error", + assertion: &saml.Assertion{ + Subject: &saml.Subject{ + NameID: &saml.NameID{Value: "testuser"}, + }, + AttributeStatements: []saml.AttributeStatement{ + { + Attributes: []saml.Attribute{ + {Name: "name", Values: []saml.AttributeValue{{Value: "Test User"}}}, + }, + }, + }, + }, + expectError: true, + errorContains: "missing required user attributes", + }, + { + name: "Nil assertion returns error", + assertion: nil, + expectError: true, + errorContains: "missing required user attributes", + }, + { + name: "Alternative attribute names (Email, displayName, etc.)", + assertion: &saml.Assertion{ + AttributeStatements: []saml.AttributeStatement{ + { + Attributes: []saml.Attribute{ + {Name: "Email", Values: []saml.AttributeValue{{Value: "user@example.com"}}}, + {Name: "displayName", Values: []saml.AttributeValue{{Value: "Display Name"}}}, + {Name: "Username", Values: []saml.AttributeValue{{Value: "displayuser"}}}, + {Name: "UserID", Values: []saml.AttributeValue{{Value: "uid456"}}}, + {Name: "Groups", Values: []saml.AttributeValue{{Value: "admins"}}}, + }, + }, + }, + }, + expectError: false, + validateUser: func(t *testing.T, user *models.User) { + assert.Equal(t, "user@example.com", user.Email) + assert.Equal(t, "Display Name", user.Name) + assert.Equal(t, "displayuser", user.Username) + assert.Equal(t, "uid456", user.ID) + assert.Equal(t, []string{"admins"}, user.Groups) + }, + }, } - permissions, err := provider.ListPermissions(ctx, &models.SearchRequest{}) - if err != nil { - t.Errorf("ListPermissions should not error: %v", err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user, err := provider.extractUserFromAssertion(tt.assertion) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, user) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.NotNil(t, user) + if tt.validateUser != nil { + tt.validateUser(t, user) + } + } + }) } - if len(permissions) != 0 { - t.Error("ListPermissions should return empty list") +} + +// TestSAMLProvider_AuthorizeSession tests SAML authorization request generation +func TestSAMLProvider_AuthorizeSession(t *testing.T) { + tests := []struct { + name string + setupProvider func(t *testing.T) *samlProvider + authRequest *models.AuthorizeUser + expectError bool + errorContains string + validateResult func(t *testing.T, response *models.AuthorizeSessionResponse) + }{ + { + name: "Successful authorization request", + setupProvider: func(t *testing.T) *samlProvider { + return createTestSAMLProvider(t) + }, + authRequest: &models.AuthorizeUser{ + State: "test-state", + RedirectUri: "https://test.example.com/callback", + }, + expectError: false, + validateResult: func(t *testing.T, response *models.AuthorizeSessionResponse) { + assert.NotEmpty(t, response.Url) + authURL, err := url.Parse(response.Url) + require.NoError(t, err) + // SAML redirect binding URL should contain SAMLRequest parameter + assert.Contains(t, authURL.String(), "SAMLRequest") + }, + }, + { + name: "Uninitialized provider", + setupProvider: func(t *testing.T) *samlProvider { + return &samlProvider{} + }, + authRequest: &models.AuthorizeUser{ + State: "test-state", + RedirectUri: "https://test.example.com/callback", + }, + expectError: true, + errorContains: "not initialized", + }, } - _, err = provider.GetRole(ctx, "test-role") - if err == nil { - t.Error("Expected not implemented error for GetRole") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := tt.setupProvider(t) + ctx := context.Background() + + response, err := provider.AuthorizeSession(ctx, tt.authRequest) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, response) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.NotNil(t, response) + if tt.validateResult != nil { + tt.validateResult(t, response) + } + } + }) } +} - roles, err := provider.ListRoles(ctx, &models.SearchRequest{}) - if err != nil { - t.Errorf("ListRoles should not error: %v", err) +// TestSAMLProvider_RenewSession tests session renewal +func TestSAMLProvider_RenewSession(t *testing.T) { + tests := []struct { + name string + session *models.Session + expectError bool + errorContains string + validateResult func(t *testing.T, original *models.Session, renewed *models.Session) + }{ + { + name: "Renew valid session", + session: &models.Session{ + UUID: uuid.New(), + User: &models.User{Username: "testuser"}, + Expiry: time.Now().Add(1 * time.Hour), + }, + expectError: false, + validateResult: func(t *testing.T, original *models.Session, renewed *models.Session) { + assert.NotEqual(t, original.UUID, renewed.UUID) + assert.Equal(t, original.User, renewed.User) + assert.True(t, renewed.Expiry.After(original.Expiry)) + }, + }, + { + name: "Cannot renew expired session", + session: &models.Session{ + UUID: uuid.New(), + User: &models.User{Username: "testuser"}, + Expiry: time.Now().Add(-1 * time.Hour), + }, + expectError: true, + errorContains: "cannot renew expired session", + }, + { + name: "Cannot renew nil session", + session: nil, + expectError: true, + errorContains: "session is nil", + }, } - if len(roles) != 0 { - t.Error("ListRoles should return empty list") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &samlProvider{} + ctx := context.Background() + + renewedSession, err := provider.RenewSession(ctx, tt.session) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, renewedSession) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + assert.NotNil(t, renewedSession) + if tt.validateResult != nil { + tt.validateResult(t, tt.session, renewedSession) + } + } + }) } +} - _, err = provider.GetResource(ctx, "test-resource") - if err == nil { - t.Error("Expected not implemented error for GetResource") +// TestSAMLProvider_ValidateRole tests role validation +func TestSAMLProvider_ValidateRole(t *testing.T) { + tests := []struct { + name string + identity *models.Identity + role *models.Role + expectError bool + }{ + { + name: "Valid identity and role", + identity: &models.Identity{ + ID: "testuser@example.com", + Label: "Test User", + }, + role: &models.Role{ + Name: "test-role", + }, + expectError: false, + }, + { + name: "Nil identity", + identity: nil, + role: &models.Role{Name: "test-role"}, + expectError: true, + }, + { + name: "Nil role", + identity: &models.Identity{ + ID: "testuser@example.com", + }, + role: nil, + expectError: true, + }, + { + name: "Both nil", + identity: nil, + role: nil, + expectError: true, + }, } - resources, err := provider.ListResources(ctx, &models.SearchRequest{}) - if err != nil { - t.Errorf("ListResources should not error: %v", err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &samlProvider{} + ctx := context.Background() + + result, err := provider.ValidateRole(ctx, tt.identity, tt.role) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Nil(t, result) + } + }) } - if len(resources) != 0 { - t.Error("ListResources should return empty list") +} + +// TestSAMLProvider_Initialize tests provider initialization with various scenarios +func TestSAMLProvider_Initialize(t *testing.T) { + tests := []struct { + name string + setupConfig func(t *testing.T) models.Provider + expectError bool + errorContains string + validate func(t *testing.T, p *samlProvider) + }{ + { + name: "Successful initialization with valid config", + setupConfig: func(t *testing.T) models.Provider { + cert, key := createTestCert(t) + certFile, keyFile := writeCertAndKeyToFiles(t, cert, key) + idpServer := createMockIDPMetadataServer(t) + t.Cleanup(idpServer.Close) + + return models.Provider{ + Name: "test-saml", + Provider: SamlProviderName, + Config: &models.BasicConfig{ + "idp_metadata_url": idpServer.URL, + "entity_id": "test-entity", + "root_url": "https://test.example.com", + "cert_file": certFile, + "key_file": keyFile, + }, + } + }, + expectError: false, + validate: func(t *testing.T, p *samlProvider) { + assert.NotNil(t, p.middleware) + assert.NotNil(t, p.idpMetadata) + assert.NotEmpty(t, p.certificates) + assert.Equal(t, "test-entity", p.middleware.ServiceProvider.EntityID) + assert.Contains(t, p.middleware.ServiceProvider.AcsURL.String(), "/api/v1/auth/callback/test-saml") + }, + }, + { + name: "Invalid IDP metadata URL", + setupConfig: func(t *testing.T) models.Provider { + cert, key := createTestCert(t) + certFile, keyFile := writeCertAndKeyToFiles(t, cert, key) + + return models.Provider{ + Name: "test-saml", + Provider: SamlProviderName, + Config: &models.BasicConfig{ + "idp_metadata_url": "https://invalid-url-that-does-not-exist.example.com/metadata", + "entity_id": "test-entity", + "root_url": "https://test.example.com", + "cert_file": certFile, + "key_file": keyFile, + }, + } + }, + expectError: true, + errorContains: "failed to fetch IdP metadata", + }, + { + name: "Certificate file does not exist", + setupConfig: func(t *testing.T) models.Provider { + idpServer := createMockIDPMetadataServer(t) + t.Cleanup(idpServer.Close) + + return models.Provider{ + Name: "test-saml", + Provider: SamlProviderName, + Config: &models.BasicConfig{ + "idp_metadata_url": idpServer.URL, + "entity_id": "test-entity", + "root_url": "https://test.example.com", + "cert_file": "/tmp/nonexistent-cert-file.pem", + "key_file": "/tmp/nonexistent-key-file.pem", + }, + } + }, + expectError: true, + errorContains: "failed to load SAML certificate", + }, + { + name: "Invalid root URL", + setupConfig: func(t *testing.T) models.Provider { + cert, key := createTestCert(t) + certFile, keyFile := writeCertAndKeyToFiles(t, cert, key) + idpServer := createMockIDPMetadataServer(t) + t.Cleanup(idpServer.Close) + + return models.Provider{ + Name: "test-saml", + Provider: SamlProviderName, + Config: &models.BasicConfig{ + "idp_metadata_url": idpServer.URL, + "entity_id": "test-entity", + "root_url": "://invalid-url", + "cert_file": certFile, + "key_file": keyFile, + }, + } + }, + expectError: true, + errorContains: "invalid root URL", + }, + { + name: "Invalid config format", + setupConfig: func(t *testing.T) models.Provider { + return models.Provider{ + Name: "test-saml", + Provider: SamlProviderName, + Config: &models.BasicConfig{ + "idp_metadata_url": "https://example.com/metadata", + // Missing required fields + }, + } + }, + expectError: true, + errorContains: "failed to parse SAML config", + }, } - err = provider.SendNotification(ctx, models.NotificationRequest{}) - if err == nil { - t.Error("Expected not implemented error for SendNotification") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &samlProvider{} + config := tt.setupConfig(t) + + err := provider.Initialize("test-saml", config) + + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + if tt.validate != nil { + tt.validate(t, provider) + } + } + }) } } diff --git a/test/go.mod b/test/go.mod index 926b78a4..8fa01613 100644 --- a/test/go.mod +++ b/test/go.mod @@ -10,7 +10,10 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.3 github.com/aws/aws-sdk-go-v2/service/iam v1.53.1 github.com/cloudevents/sdk-go/v2 v2.16.2 + github.com/gin-contrib/sessions v1.0.4 + github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 + github.com/gorilla/csrf v1.7.3 github.com/serverlessworkflow/sdk-go/v3 v3.2.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.39.0 @@ -44,6 +47,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 // indirect @@ -63,6 +67,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 // indirect github.com/aws/smithy-go v1.24.0 // indirect + github.com/beevik/etree v1.5.0 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/blevesearch/bleve/v2 v2.5.5 // indirect github.com/blevesearch/bleve_index_api v1.2.11 // indirect @@ -82,15 +87,20 @@ require ( github.com/blevesearch/zapx/v14 v14.4.2 // indirect github.com/blevesearch/zapx/v15 v15.4.2 // indirect github.com/blevesearch/zapx/v16 v16.2.7 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.2 // indirect + github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/cloudflare-go v0.116.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/crewjam/saml v0.5.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denisbrodbeck/machineid v1.0.1 // indirect github.com/distribution/reference v0.6.0 // indirect @@ -106,6 +116,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-co-op/gocron v1.37.0 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect @@ -114,6 +125,7 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.3 // indirect github.com/go-openapi/jsonreference v0.21.3 // indirect + github.com/go-openapi/spec v0.22.1 // indirect github.com/go-openapi/swag v0.25.3 // indirect github.com/go-openapi/swag/cmdutils v0.25.3 // indirect github.com/go-openapi/swag/conv v0.25.3 // indirect @@ -132,7 +144,9 @@ require ( github.com/go-resty/resty/v2 v2.17.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/snappy v1.0.0 // indirect @@ -144,6 +158,9 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.4.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect @@ -165,15 +182,19 @@ require ( github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20240226150601-1dcf7310316a // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.9.1 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/microsoft/kiota-abstractions-go v1.9.3 // indirect github.com/microsoft/kiota-authentication-azure-go v1.3.1 // indirect @@ -212,8 +233,11 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.57.0 // indirect github.com/robfig/cron v1.2.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/russellhaering/goxmldsig v1.4.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/senseyeio/duration v0.0.0-20180430131211-7c2a214ada46 // indirect @@ -228,11 +252,17 @@ require ( github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/swaggo/files v1.0.1 // indirect + github.com/swaggo/gin-swagger v1.6.1 // indirect + github.com/swaggo/swag v1.16.6 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tklauser/go-sysconf v0.3.13 // indirect github.com/tklauser/numcpus v0.7.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/ulule/limiter/v3 v3.11.2 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -256,6 +286,7 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect @@ -265,6 +296,7 @@ require ( golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.39.0 // indirect google.golang.org/api v0.257.0 // indirect google.golang.org/genai v1.36.0 // indirect google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba // indirect diff --git a/test/go.sum b/test/go.sum index e58fa42f..97974052 100644 --- a/test/go.sum +++ b/test/go.sum @@ -48,6 +48,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw= @@ -94,6 +96,9 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 h1:tzMkjh0yTChUqJDgGkcDdxvZDSrJ github.com/aws/aws-sdk-go-v2/service/sts v1.41.3/go.mod h1:T270C0R5sZNLbWUe8ueiAF42XSZxxPocTaGSgs5c/60= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= +github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blevesearch/bleve/v2 v2.5.5 h1:lzC89QUCco+y1qBnJxGqm4AbtsdsnlUvq0kXok8n3C8= @@ -132,6 +137,12 @@ github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFx github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= github.com/blevesearch/zapx/v16 v16.2.7 h1:xcgFRa7f/tQXOwApVq7JWgPYSlzyUMmkuYa54tMDuR0= github.com/blevesearch/zapx/v16 v16.2.7/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= +github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= +github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= +github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -142,6 +153,8 @@ github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= github.com/cloudflare/cloudflare-go v0.116.0 h1:iRPMnTtnswRpELO65NTwMX4+RTdxZl+Xf/zi+HPE95s= github.com/cloudflare/cloudflare-go v0.116.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -157,6 +170,8 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI= +github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -198,6 +213,14 @@ github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U= +github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= @@ -216,6 +239,8 @@ github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYA github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= +github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k= +github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA= github.com/go-openapi/swag v0.25.3 h1:FAa5wJXyDtI7yUztKDfZxDrSx+8WTg31MfCQ9s3PV+s= github.com/go-openapi/swag v0.25.3/go.mod h1:tX9vI8Mj8Ny+uCEk39I1QADvIPI7lkndX4qCsEqhkS8= github.com/go-openapi/swag/cmdutils v0.25.3 h1:EIwGxN143JCThNHnqfqs85R8lJcJG06qjJRZp3VvjLI= @@ -264,8 +289,12 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= @@ -288,6 +317,8 @@ github.com/google/go-github/v57 v57.0.0/go.mod h1:s0omdnye0hvK/ecLvpsGfJMiRt85Pi github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -299,6 +330,14 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAV github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= +github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= @@ -347,6 +386,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -359,6 +400,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -378,6 +421,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -470,14 +515,21 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= +github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= +github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= @@ -507,17 +559,27 @@ github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8/go.mod h1:Z5KcoM0YLC7INl github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts= github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8= github.com/testcontainers/testcontainers-go/modules/localstack v0.39.0 h1:KI2cNWG8eDZKvswnz1NJhVZla0bo1WTRTFPMWDYzJ7w= @@ -534,8 +596,12 @@ github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08 github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0= github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4= github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA= +github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= @@ -596,6 +662,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= @@ -604,6 +672,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -626,6 +696,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= @@ -654,6 +725,7 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= @@ -718,12 +790,15 @@ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkp gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4=