diff --git a/.github/fixtures/workflow-compat.yaml b/.github/fixtures/workflow-compat.yaml index ed5df3e..beeb41e 100644 --- a/.github/fixtures/workflow-compat.yaml +++ b/.github/fixtures/workflow-compat.yaml @@ -58,12 +58,12 @@ workflows: policy_step: methods_policy signing_secret: "test-secret" jwt_secret: "test-jwt-secret" + # tenant_id added in v0.2.2: + tenant_id: "tenant-smoke" required_runtime_keys: - tenant_id oauth_supported_providers: - google - # Added in v0.2.2: - tenant_id: "tenant-smoke" - name: methods_response type: step.auth_methods_response @@ -127,6 +127,10 @@ workflows: - name: challenge_generate type: step.auth_challenge_generate + config: + # Fields added in v0.2.3 (replaced EmptyConfig with AuthChallengeGenerateConfig): + signing_secret: "test-secret" + ttl_minutes: 10 - name: challenge_verify type: step.auth_challenge_verify diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d2ce2..90fbc7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v0.2.3 (2026-05-13) + +### Strict-proto config-field gaps closed (BMW local smoke vs workflow v0.51.5, round 2) + +Round 2 closes two gaps the v0.2.2 sweep missed: + +- `step.auth_challenge_generate`: replaced `EmptyConfig` with new + `AuthChallengeGenerateConfig { string signing_secret = 1; int32 ttl_minutes = 2; }`. + BMW passes both fields in the step's `config:` block; under v0.2.2 they were + rejected by strict-proto validation because the contract was `EmptyConfig`. + The handler now falls back to `config.signing_secret` and `config.ttl_minutes` + when the corresponding input field is empty/zero. +- `AuthPolicyGateConfig`: verified all four BMW-supplied fields (`policy_step`, + `signing_secret`, `tenant_id`, `required_runtime_keys`) are present and round-trip + through strict-proto validation. Added `TestAuthPolicyGateConfig_AcceptsAllBMWFields` + as a regression guard against future field drift. + +### Tests + +- Added `TestAuthChallengeGenerateConfig_AcceptsSigningSecretAndTTL` (strict-proto + validation accepts the new config message). +- Added `TestChallengeGenerate_FallsBackToConfigSigningSecret` and + `TestChallengeGenerate_FallsBackToConfigTTL` (handler honors config fallbacks + when input does not carry the value). +- Added `TestAuthPolicyGateConfig_AcceptsAllBMWFields` exhaustiveness regression test. + ## v0.2.2 (2026-05-13) ### Strict-proto config-field gaps closed (BMW local smoke vs workflow v0.51.5) diff --git a/internal/contracts/auth.pb.go b/internal/contracts/auth.pb.go index e66d036..fe5adab 100644 --- a/internal/contracts/auth.pb.go +++ b/internal/contracts/auth.pb.go @@ -2970,6 +2970,58 @@ func (x *AuthChallengeVerifyConfig) GetSigningSecret() string { return "" } +type AuthChallengeGenerateConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + SigningSecret string `protobuf:"bytes,1,opt,name=signing_secret,json=signingSecret,proto3" json:"signing_secret,omitempty"` + TtlMinutes int32 `protobuf:"varint,2,opt,name=ttl_minutes,json=ttlMinutes,proto3" json:"ttl_minutes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AuthChallengeGenerateConfig) Reset() { + *x = AuthChallengeGenerateConfig{} + mi := &file_internal_contracts_auth_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AuthChallengeGenerateConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthChallengeGenerateConfig) ProtoMessage() {} + +func (x *AuthChallengeGenerateConfig) ProtoReflect() protoreflect.Message { + mi := &file_internal_contracts_auth_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthChallengeGenerateConfig.ProtoReflect.Descriptor instead. +func (*AuthChallengeGenerateConfig) Descriptor() ([]byte, []int) { + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{39} +} + +func (x *AuthChallengeGenerateConfig) GetSigningSecret() string { + if x != nil { + return x.SigningSecret + } + return "" +} + +func (x *AuthChallengeGenerateConfig) GetTtlMinutes() int32 { + if x != nil { + return x.TtlMinutes + } + return 0 +} + type AuthPolicyGateInput struct { state protoimpl.MessageState `protogen:"open.v1"` PasskeyEnabled bool `protobuf:"varint,1,opt,name=passkey_enabled,json=passkeyEnabled,proto3" json:"passkey_enabled,omitempty"` @@ -2988,7 +3040,7 @@ type AuthPolicyGateInput struct { func (x *AuthPolicyGateInput) Reset() { *x = AuthPolicyGateInput{} - mi := &file_internal_contracts_auth_proto_msgTypes[39] + mi := &file_internal_contracts_auth_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3000,7 +3052,7 @@ func (x *AuthPolicyGateInput) String() string { func (*AuthPolicyGateInput) ProtoMessage() {} func (x *AuthPolicyGateInput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[39] + mi := &file_internal_contracts_auth_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3013,7 +3065,7 @@ func (x *AuthPolicyGateInput) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthPolicyGateInput.ProtoReflect.Descriptor instead. func (*AuthPolicyGateInput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{39} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{40} } func (x *AuthPolicyGateInput) GetPasskeyEnabled() bool { @@ -3104,7 +3156,7 @@ type AuthMethodsResponseOutput struct { func (x *AuthMethodsResponseOutput) Reset() { *x = AuthMethodsResponseOutput{} - mi := &file_internal_contracts_auth_proto_msgTypes[40] + mi := &file_internal_contracts_auth_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3116,7 +3168,7 @@ func (x *AuthMethodsResponseOutput) String() string { func (*AuthMethodsResponseOutput) ProtoMessage() {} func (x *AuthMethodsResponseOutput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[40] + mi := &file_internal_contracts_auth_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3129,7 +3181,7 @@ func (x *AuthMethodsResponseOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthMethodsResponseOutput.ProtoReflect.Descriptor instead. func (*AuthMethodsResponseOutput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{40} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{41} } func (x *AuthMethodsResponseOutput) GetPasskeyEnabled() bool { @@ -3213,7 +3265,7 @@ type AuthPolicyAuditOutput struct { func (x *AuthPolicyAuditOutput) Reset() { *x = AuthPolicyAuditOutput{} - mi := &file_internal_contracts_auth_proto_msgTypes[41] + mi := &file_internal_contracts_auth_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3225,7 +3277,7 @@ func (x *AuthPolicyAuditOutput) String() string { func (*AuthPolicyAuditOutput) ProtoMessage() {} func (x *AuthPolicyAuditOutput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[41] + mi := &file_internal_contracts_auth_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3238,7 +3290,7 @@ func (x *AuthPolicyAuditOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthPolicyAuditOutput.ProtoReflect.Descriptor instead. func (*AuthPolicyAuditOutput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{41} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{42} } func (x *AuthPolicyAuditOutput) GetPassed() bool { @@ -3280,7 +3332,7 @@ type OAuthProviderConfig struct { func (x *OAuthProviderConfig) Reset() { *x = OAuthProviderConfig{} - mi := &file_internal_contracts_auth_proto_msgTypes[42] + mi := &file_internal_contracts_auth_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3292,7 +3344,7 @@ func (x *OAuthProviderConfig) String() string { func (*OAuthProviderConfig) ProtoMessage() {} func (x *OAuthProviderConfig) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[42] + mi := &file_internal_contracts_auth_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3305,7 +3357,7 @@ func (x *OAuthProviderConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use OAuthProviderConfig.ProtoReflect.Descriptor instead. func (*OAuthProviderConfig) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{42} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{43} } func (x *OAuthProviderConfig) GetProvider() string { @@ -3392,7 +3444,7 @@ type OAuthProviderInput struct { func (x *OAuthProviderInput) Reset() { *x = OAuthProviderInput{} - mi := &file_internal_contracts_auth_proto_msgTypes[43] + mi := &file_internal_contracts_auth_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3404,7 +3456,7 @@ func (x *OAuthProviderInput) String() string { func (*OAuthProviderInput) ProtoMessage() {} func (x *OAuthProviderInput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[43] + mi := &file_internal_contracts_auth_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3417,7 +3469,7 @@ func (x *OAuthProviderInput) ProtoReflect() protoreflect.Message { // Deprecated: Use OAuthProviderInput.ProtoReflect.Descriptor instead. func (*OAuthProviderInput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{43} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{44} } func (x *OAuthProviderInput) GetProvider() string { @@ -3480,7 +3532,7 @@ type OAuthProviderConfigOutput struct { func (x *OAuthProviderConfigOutput) Reset() { *x = OAuthProviderConfigOutput{} - mi := &file_internal_contracts_auth_proto_msgTypes[44] + mi := &file_internal_contracts_auth_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3492,7 +3544,7 @@ func (x *OAuthProviderConfigOutput) String() string { func (*OAuthProviderConfigOutput) ProtoMessage() {} func (x *OAuthProviderConfigOutput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[44] + mi := &file_internal_contracts_auth_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3505,7 +3557,7 @@ func (x *OAuthProviderConfigOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use OAuthProviderConfigOutput.ProtoReflect.Descriptor instead. func (*OAuthProviderConfigOutput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{44} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{45} } func (x *OAuthProviderConfigOutput) GetProvider() string { @@ -3597,7 +3649,7 @@ type OAuthStartOutput struct { func (x *OAuthStartOutput) Reset() { *x = OAuthStartOutput{} - mi := &file_internal_contracts_auth_proto_msgTypes[45] + mi := &file_internal_contracts_auth_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3609,7 +3661,7 @@ func (x *OAuthStartOutput) String() string { func (*OAuthStartOutput) ProtoMessage() {} func (x *OAuthStartOutput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[45] + mi := &file_internal_contracts_auth_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3622,7 +3674,7 @@ func (x *OAuthStartOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use OAuthStartOutput.ProtoReflect.Descriptor instead. func (*OAuthStartOutput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{45} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{46} } func (x *OAuthStartOutput) GetStarted() bool { @@ -3720,7 +3772,7 @@ type OAuthExchangeOutput struct { func (x *OAuthExchangeOutput) Reset() { *x = OAuthExchangeOutput{} - mi := &file_internal_contracts_auth_proto_msgTypes[46] + mi := &file_internal_contracts_auth_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3732,7 +3784,7 @@ func (x *OAuthExchangeOutput) String() string { func (*OAuthExchangeOutput) ProtoMessage() {} func (x *OAuthExchangeOutput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[46] + mi := &file_internal_contracts_auth_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3745,7 +3797,7 @@ func (x *OAuthExchangeOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use OAuthExchangeOutput.ProtoReflect.Descriptor instead. func (*OAuthExchangeOutput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{46} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{47} } func (x *OAuthExchangeOutput) GetExchanged() bool { @@ -3836,7 +3888,7 @@ type OAuthUserinfoOutput struct { func (x *OAuthUserinfoOutput) Reset() { *x = OAuthUserinfoOutput{} - mi := &file_internal_contracts_auth_proto_msgTypes[47] + mi := &file_internal_contracts_auth_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3848,7 +3900,7 @@ func (x *OAuthUserinfoOutput) String() string { func (*OAuthUserinfoOutput) ProtoMessage() {} func (x *OAuthUserinfoOutput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[47] + mi := &file_internal_contracts_auth_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3861,7 +3913,7 @@ func (x *OAuthUserinfoOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use OAuthUserinfoOutput.ProtoReflect.Descriptor instead. func (*OAuthUserinfoOutput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{47} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{48} } func (x *OAuthUserinfoOutput) GetFetched() bool { @@ -3943,7 +3995,7 @@ type CredentialListInput struct { func (x *CredentialListInput) Reset() { *x = CredentialListInput{} - mi := &file_internal_contracts_auth_proto_msgTypes[48] + mi := &file_internal_contracts_auth_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3955,7 +4007,7 @@ func (x *CredentialListInput) String() string { func (*CredentialListInput) ProtoMessage() {} func (x *CredentialListInput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[48] + mi := &file_internal_contracts_auth_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3968,7 +4020,7 @@ func (x *CredentialListInput) ProtoReflect() protoreflect.Message { // Deprecated: Use CredentialListInput.ProtoReflect.Descriptor instead. func (*CredentialListInput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{48} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{49} } func (x *CredentialListInput) GetCredentialsJson() string { @@ -3991,7 +4043,7 @@ type CredentialSummary struct { func (x *CredentialSummary) Reset() { *x = CredentialSummary{} - mi := &file_internal_contracts_auth_proto_msgTypes[49] + mi := &file_internal_contracts_auth_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4003,7 +4055,7 @@ func (x *CredentialSummary) String() string { func (*CredentialSummary) ProtoMessage() {} func (x *CredentialSummary) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[49] + mi := &file_internal_contracts_auth_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4016,7 +4068,7 @@ func (x *CredentialSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use CredentialSummary.ProtoReflect.Descriptor instead. func (*CredentialSummary) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{49} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{50} } func (x *CredentialSummary) GetId() string { @@ -4065,7 +4117,7 @@ type CredentialListOutput struct { func (x *CredentialListOutput) Reset() { *x = CredentialListOutput{} - mi := &file_internal_contracts_auth_proto_msgTypes[50] + mi := &file_internal_contracts_auth_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4077,7 +4129,7 @@ func (x *CredentialListOutput) String() string { func (*CredentialListOutput) ProtoMessage() {} func (x *CredentialListOutput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[50] + mi := &file_internal_contracts_auth_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4090,7 +4142,7 @@ func (x *CredentialListOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use CredentialListOutput.ProtoReflect.Descriptor instead. func (*CredentialListOutput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{50} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{51} } func (x *CredentialListOutput) GetCredentials() []*CredentialSummary { @@ -4125,7 +4177,7 @@ type CredentialRevokeInput struct { func (x *CredentialRevokeInput) Reset() { *x = CredentialRevokeInput{} - mi := &file_internal_contracts_auth_proto_msgTypes[51] + mi := &file_internal_contracts_auth_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4137,7 +4189,7 @@ func (x *CredentialRevokeInput) String() string { func (*CredentialRevokeInput) ProtoMessage() {} func (x *CredentialRevokeInput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[51] + mi := &file_internal_contracts_auth_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4150,7 +4202,7 @@ func (x *CredentialRevokeInput) ProtoReflect() protoreflect.Message { // Deprecated: Use CredentialRevokeInput.ProtoReflect.Descriptor instead. func (*CredentialRevokeInput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{51} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{52} } func (x *CredentialRevokeInput) GetCredentialId() string { @@ -4185,7 +4237,7 @@ type CredentialRevokeOutput struct { func (x *CredentialRevokeOutput) Reset() { *x = CredentialRevokeOutput{} - mi := &file_internal_contracts_auth_proto_msgTypes[52] + mi := &file_internal_contracts_auth_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4197,7 +4249,7 @@ func (x *CredentialRevokeOutput) String() string { func (*CredentialRevokeOutput) ProtoMessage() {} func (x *CredentialRevokeOutput) ProtoReflect() protoreflect.Message { - mi := &file_internal_contracts_auth_proto_msgTypes[52] + mi := &file_internal_contracts_auth_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4210,7 +4262,7 @@ func (x *CredentialRevokeOutput) ProtoReflect() protoreflect.Message { // Deprecated: Use CredentialRevokeOutput.ProtoReflect.Descriptor instead. func (*CredentialRevokeOutput) Descriptor() ([]byte, []int) { - return file_internal_contracts_auth_proto_rawDescGZIP(), []int{52} + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{53} } func (x *CredentialRevokeOutput) GetAuthorized() bool { @@ -4511,7 +4563,11 @@ const file_internal_contracts_auth_proto_rawDesc = "" + "\x19oauth_supported_providers\x18\x05 \x03(\tR\x17oauthSupportedProviders\x12\x1b\n" + "\ttenant_id\x18\x06 \x01(\tR\btenantId\"B\n" + "\x19AuthChallengeVerifyConfig\x12%\n" + - "\x0esigning_secret\x18\x01 \x01(\tR\rsigningSecret\"\xb9\x03\n" + + "\x0esigning_secret\x18\x01 \x01(\tR\rsigningSecret\"e\n" + + "\x1bAuthChallengeGenerateConfig\x12%\n" + + "\x0esigning_secret\x18\x01 \x01(\tR\rsigningSecret\x12\x1f\n" + + "\vttl_minutes\x18\x02 \x01(\x05R\n" + + "ttlMinutes\"\xb9\x03\n" + "\x13AuthPolicyGateInput\x12'\n" + "\x0fpasskey_enabled\x18\x01 \x01(\bR\x0epasskeyEnabled\x12,\n" + "\x12email_code_enabled\x18\x02 \x01(\bR\x10emailCodeEnabled\x12(\n" + @@ -4652,7 +4708,7 @@ func file_internal_contracts_auth_proto_rawDescGZIP() []byte { return file_internal_contracts_auth_proto_rawDescData } -var file_internal_contracts_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 53) +var file_internal_contracts_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 54) var file_internal_contracts_auth_proto_goTypes = []any{ (*CredentialModuleConfig)(nil), // 0: workflow.plugins.auth.v1.CredentialModuleConfig (*PasskeyStepConfig)(nil), // 1: workflow.plugins.auth.v1.PasskeyStepConfig @@ -4693,26 +4749,27 @@ var file_internal_contracts_auth_proto_goTypes = []any{ (*AuthMethodsPolicyOutput)(nil), // 36: workflow.plugins.auth.v1.AuthMethodsPolicyOutput (*AuthPolicyGateConfig)(nil), // 37: workflow.plugins.auth.v1.AuthPolicyGateConfig (*AuthChallengeVerifyConfig)(nil), // 38: workflow.plugins.auth.v1.AuthChallengeVerifyConfig - (*AuthPolicyGateInput)(nil), // 39: workflow.plugins.auth.v1.AuthPolicyGateInput - (*AuthMethodsResponseOutput)(nil), // 40: workflow.plugins.auth.v1.AuthMethodsResponseOutput - (*AuthPolicyAuditOutput)(nil), // 41: workflow.plugins.auth.v1.AuthPolicyAuditOutput - (*OAuthProviderConfig)(nil), // 42: workflow.plugins.auth.v1.OAuthProviderConfig - (*OAuthProviderInput)(nil), // 43: workflow.plugins.auth.v1.OAuthProviderInput - (*OAuthProviderConfigOutput)(nil), // 44: workflow.plugins.auth.v1.OAuthProviderConfigOutput - (*OAuthStartOutput)(nil), // 45: workflow.plugins.auth.v1.OAuthStartOutput - (*OAuthExchangeOutput)(nil), // 46: workflow.plugins.auth.v1.OAuthExchangeOutput - (*OAuthUserinfoOutput)(nil), // 47: workflow.plugins.auth.v1.OAuthUserinfoOutput - (*CredentialListInput)(nil), // 48: workflow.plugins.auth.v1.CredentialListInput - (*CredentialSummary)(nil), // 49: workflow.plugins.auth.v1.CredentialSummary - (*CredentialListOutput)(nil), // 50: workflow.plugins.auth.v1.CredentialListOutput - (*CredentialRevokeInput)(nil), // 51: workflow.plugins.auth.v1.CredentialRevokeInput - (*CredentialRevokeOutput)(nil), // 52: workflow.plugins.auth.v1.CredentialRevokeOutput - (*structpb.Struct)(nil), // 53: google.protobuf.Struct + (*AuthChallengeGenerateConfig)(nil), // 39: workflow.plugins.auth.v1.AuthChallengeGenerateConfig + (*AuthPolicyGateInput)(nil), // 40: workflow.plugins.auth.v1.AuthPolicyGateInput + (*AuthMethodsResponseOutput)(nil), // 41: workflow.plugins.auth.v1.AuthMethodsResponseOutput + (*AuthPolicyAuditOutput)(nil), // 42: workflow.plugins.auth.v1.AuthPolicyAuditOutput + (*OAuthProviderConfig)(nil), // 43: workflow.plugins.auth.v1.OAuthProviderConfig + (*OAuthProviderInput)(nil), // 44: workflow.plugins.auth.v1.OAuthProviderInput + (*OAuthProviderConfigOutput)(nil), // 45: workflow.plugins.auth.v1.OAuthProviderConfigOutput + (*OAuthStartOutput)(nil), // 46: workflow.plugins.auth.v1.OAuthStartOutput + (*OAuthExchangeOutput)(nil), // 47: workflow.plugins.auth.v1.OAuthExchangeOutput + (*OAuthUserinfoOutput)(nil), // 48: workflow.plugins.auth.v1.OAuthUserinfoOutput + (*CredentialListInput)(nil), // 49: workflow.plugins.auth.v1.CredentialListInput + (*CredentialSummary)(nil), // 50: workflow.plugins.auth.v1.CredentialSummary + (*CredentialListOutput)(nil), // 51: workflow.plugins.auth.v1.CredentialListOutput + (*CredentialRevokeInput)(nil), // 52: workflow.plugins.auth.v1.CredentialRevokeInput + (*CredentialRevokeOutput)(nil), // 53: workflow.plugins.auth.v1.CredentialRevokeOutput + (*structpb.Struct)(nil), // 54: google.protobuf.Struct } var file_internal_contracts_auth_proto_depIdxs = []int32{ - 53, // 0: workflow.plugins.auth.v1.OAuthExchangeOutput.raw_tokens:type_name -> google.protobuf.Struct - 53, // 1: workflow.plugins.auth.v1.OAuthUserinfoOutput.raw_claims:type_name -> google.protobuf.Struct - 49, // 2: workflow.plugins.auth.v1.CredentialListOutput.credentials:type_name -> workflow.plugins.auth.v1.CredentialSummary + 54, // 0: workflow.plugins.auth.v1.OAuthExchangeOutput.raw_tokens:type_name -> google.protobuf.Struct + 54, // 1: workflow.plugins.auth.v1.OAuthUserinfoOutput.raw_claims:type_name -> google.protobuf.Struct + 50, // 2: workflow.plugins.auth.v1.CredentialListOutput.credentials:type_name -> workflow.plugins.auth.v1.CredentialSummary 3, // [3:3] is the sub-list for method output_type 3, // [3:3] is the sub-list for method input_type 3, // [3:3] is the sub-list for extension type_name @@ -4727,15 +4784,15 @@ func file_internal_contracts_auth_proto_init() { } file_internal_contracts_auth_proto_msgTypes[34].OneofWrappers = []any{} file_internal_contracts_auth_proto_msgTypes[35].OneofWrappers = []any{} - file_internal_contracts_auth_proto_msgTypes[42].OneofWrappers = []any{} file_internal_contracts_auth_proto_msgTypes[43].OneofWrappers = []any{} + file_internal_contracts_auth_proto_msgTypes[44].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_contracts_auth_proto_rawDesc), len(file_internal_contracts_auth_proto_rawDesc)), NumEnums: 0, - NumMessages: 53, + NumMessages: 54, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/contracts/auth.proto b/internal/contracts/auth.proto index 97eceb5..8ce0e11 100644 --- a/internal/contracts/auth.proto +++ b/internal/contracts/auth.proto @@ -315,6 +315,11 @@ message AuthChallengeVerifyConfig { string signing_secret = 1; } +message AuthChallengeGenerateConfig { + string signing_secret = 1; + int32 ttl_minutes = 2; +} + message AuthPolicyGateInput { bool passkey_enabled = 1; bool email_code_enabled = 2; diff --git a/internal/plugin.go b/internal/plugin.go index e5d00bb..76fe207 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -206,7 +206,7 @@ func (p *authPlugin) CreateTypedStep(typeName, name string, config *anypb.Any) ( return newPasswordVerifyStep(name, config) }, &contracts.PasswordVerifyOutput{})).CreateTypedStep(typeName, name, config) case "step.auth_challenge_generate": - return sdk.NewTypedStepFactory(typeName, &contracts.EmptyConfig{}, &contracts.ChallengeGenerateInput{}, typedLegacyStep[*contracts.EmptyConfig, *contracts.ChallengeGenerateInput, *contracts.ChallengeGenerateOutput](func(name string, config map[string]any) sdk.StepInstance { + return sdk.NewTypedStepFactory(typeName, &contracts.AuthChallengeGenerateConfig{}, &contracts.ChallengeGenerateInput{}, typedLegacyStep[*contracts.AuthChallengeGenerateConfig, *contracts.ChallengeGenerateInput, *contracts.ChallengeGenerateOutput](func(name string, config map[string]any) sdk.StepInstance { return newChallengeGenerateStep(name, config) }, &contracts.ChallengeGenerateOutput{})).CreateTypedStep(typeName, name, config) case "step.auth_challenge_verify": @@ -283,7 +283,7 @@ var authContractRegistry = &pb.ContractRegistry{ stepContract("step.auth_magic_link_send", "MagicLinkSendConfig", "MagicLinkSendInput", "MagicLinkSendOutput"), stepContract("step.auth_password_hash", "EmptyConfig", "PasswordHashInput", "PasswordHashOutput"), stepContract("step.auth_password_verify", "EmptyConfig", "PasswordVerifyInput", "PasswordVerifyOutput"), - stepContract("step.auth_challenge_generate", "EmptyConfig", "ChallengeGenerateInput", "ChallengeGenerateOutput"), + stepContract("step.auth_challenge_generate", "AuthChallengeGenerateConfig", "ChallengeGenerateInput", "ChallengeGenerateOutput"), stepContract("step.auth_challenge_verify", "AuthChallengeVerifyConfig", "ChallengeVerifyInput", "ChallengeVerifyOutput"), stepContract("step.auth_normalize_phone", "EmptyConfig", "NormalizePhoneInput", "NormalizePhoneOutput"), stepContract("step.auth_methods_policy", "AuthMethodsPolicyConfig", "AuthMethodsPolicyInput", "AuthMethodsPolicyOutput"), diff --git a/internal/plugin_contracts_test.go b/internal/plugin_contracts_test.go index af5b231..e4ca323 100644 --- a/internal/plugin_contracts_test.go +++ b/internal/plugin_contracts_test.go @@ -53,7 +53,7 @@ func TestContractRegistryDeclaresStrictContracts(t *testing.T) { } requireContract(t, runtimeContracts, "module:auth.credential", "workflow.plugins.auth.v1.CredentialModuleConfig", "", "") - requireContract(t, runtimeContracts, "step:step.auth_challenge_generate", "workflow.plugins.auth.v1.EmptyConfig", "workflow.plugins.auth.v1.ChallengeGenerateInput", "workflow.plugins.auth.v1.ChallengeGenerateOutput") + requireContract(t, runtimeContracts, "step:step.auth_challenge_generate", "workflow.plugins.auth.v1.AuthChallengeGenerateConfig", "workflow.plugins.auth.v1.ChallengeGenerateInput", "workflow.plugins.auth.v1.ChallengeGenerateOutput") requireContract(t, runtimeContracts, "step:step.auth_methods_policy", "workflow.plugins.auth.v1.AuthMethodsPolicyConfig", "workflow.plugins.auth.v1.AuthMethodsPolicyInput", "workflow.plugins.auth.v1.AuthMethodsPolicyOutput") } diff --git a/internal/step_challenge.go b/internal/step_challenge.go index c4343d9..ded8e90 100644 --- a/internal/step_challenge.go +++ b/internal/step_challenge.go @@ -15,10 +15,13 @@ import ( sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) -type challengeGenerateStep struct{ name string } +type challengeGenerateStep struct { + name string + config map[string]any +} -func newChallengeGenerateStep(name string, _ map[string]any) *challengeGenerateStep { - return &challengeGenerateStep{name: name} +func newChallengeGenerateStep(name string, config map[string]any) *challengeGenerateStep { + return &challengeGenerateStep{name: name, config: config} } func (s *challengeGenerateStep) Execute(_ context.Context, _ map[string]any, _ map[string]map[string]any, current, _, _ map[string]any) (*sdk.StepResult, error) { @@ -27,6 +30,11 @@ func (s *challengeGenerateStep) Execute(_ context.Context, _ map[string]any, _ m tenantID, _ := current["tenant_id"].(string) purpose, _ := current["purpose"].(string) signingSecret, _ := current["signing_secret"].(string) + if signingSecret == "" { + if v, ok := s.config["signing_secret"].(string); ok { + signingSecret = v + } + } if strings.TrimSpace(channel) == "" || strings.TrimSpace(destination) == "" || strings.TrimSpace(tenantID) == "" || strings.TrimSpace(purpose) == "" || signingSecret == "" { return &sdk.StepResult{Output: map[string]any{"error": "missing channel, destination, tenant_id, purpose, or signing_secret"}}, nil } @@ -37,7 +45,10 @@ func (s *challengeGenerateStep) Execute(_ context.Context, _ map[string]any, _ m return nil, fmt.Errorf("generate challenge code: %w", err) } - ttlMinutes := intFromAny(current["ttl_minutes"], 10) + ttlMinutes := intFromAny(current["ttl_minutes"], 0) + if ttlMinutes <= 0 { + ttlMinutes = intFromAny(s.config["ttl_minutes"], 0) + } if ttlMinutes <= 0 { ttlMinutes = 10 } diff --git a/internal/strict_proto_fields_test.go b/internal/strict_proto_fields_test.go index b9936f9..8e6fba8 100644 --- a/internal/strict_proto_fields_test.go +++ b/internal/strict_proto_fields_test.go @@ -3,6 +3,7 @@ package internal import ( "context" "testing" + "time" "github.com/GoCodeAlone/workflow-plugin-auth/internal/contracts" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" @@ -69,6 +70,106 @@ func TestAuthPolicyGateConfig_AcceptsTenantID(t *testing.T) { } } +// TestAuthPolicyGateConfig_AcceptsAllBMWFields ensures every config key BMW +// passes to step.auth_policy_gate (policy_step, signing_secret, tenant_id, +// required_runtime_keys) is accepted under strict-proto validation. Closes the +// round-2 exhaustiveness gap on the v0.2.2 partial fix. +func TestAuthPolicyGateConfig_AcceptsAllBMWFields(t *testing.T) { + cfg := &contracts.AuthPolicyGateConfig{ + PolicyStep: "email_policy", + SigningSecret: "jwt-secret", + JwtSecret: "jwt-secret", + RequiredRuntimeKeys: []string{"tenant_id"}, + TenantId: "tenant-123", + } + packed, err := anypb.New(cfg) + if err != nil { + t.Fatalf("pack config: %v", err) + } + provider := NewAuthPlugin().(interface { + CreateTypedStep(typeName, name string, config *anypb.Any) (sdk.StepInstance, error) + }) + if _, err := provider.CreateTypedStep("step.auth_policy_gate", "gate", packed); err != nil { + t.Fatalf("CreateTypedStep rejected combined BMW fields: %v", err) + } +} + +// TestAuthChallengeGenerateConfig_AcceptsSigningSecretAndTTL ensures BMW's +// config-supplied signing_secret + ttl_minutes for step.auth_challenge_generate +// pass strict-proto validation under the new AuthChallengeGenerateConfig. +func TestAuthChallengeGenerateConfig_AcceptsSigningSecretAndTTL(t *testing.T) { + cfg := &contracts.AuthChallengeGenerateConfig{ + SigningSecret: "jwt-secret", + TtlMinutes: 10, + } + packed, err := anypb.New(cfg) + if err != nil { + t.Fatalf("pack config: %v", err) + } + provider := NewAuthPlugin().(interface { + CreateTypedStep(typeName, name string, config *anypb.Any) (sdk.StepInstance, error) + }) + if _, err := provider.CreateTypedStep("step.auth_challenge_generate", "generate", packed); err != nil { + t.Fatalf("CreateTypedStep rejected challenge_generate config: %v", err) + } +} + +// TestChallengeGenerate_FallsBackToConfigSigningSecret ensures signing_secret +// supplied via config (not input) is honored by the handler. +func TestChallengeGenerate_FallsBackToConfigSigningSecret(t *testing.T) { + gen := newChallengeGenerateStep("generate", map[string]any{ + "signing_secret": "shared-secret", + }) + genResult, err := gen.Execute(context.Background(), nil, nil, map[string]any{ + "channel": "email", + "destination": "user@example.com", + "tenant_id": "tenant-123", + "purpose": "login", + }, nil, nil) + if err != nil { + t.Fatalf("generate: %v", err) + } + if errStr, _ := genResult.Output["error"].(string); errStr != "" { + t.Fatalf("expected no error when signing_secret supplied via config, got %q", errStr) + } + if code, _ := genResult.Output["code"].(string); code == "" { + t.Fatalf("expected code generation when signing_secret supplied via config, got %#v", genResult.Output) + } +} + +// TestChallengeGenerate_FallsBackToConfigTTL ensures ttl_minutes supplied via +// config (not input) is honored by the handler. +func TestChallengeGenerate_FallsBackToConfigTTL(t *testing.T) { + gen := newChallengeGenerateStep("generate", map[string]any{ + "signing_secret": "shared-secret", + "ttl_minutes": 2, + }) + before := time.Now().UTC() + genResult, err := gen.Execute(context.Background(), nil, nil, map[string]any{ + "channel": "email", + "destination": "user@example.com", + "tenant_id": "tenant-123", + "purpose": "login", + }, nil, nil) + after := time.Now().UTC() + if err != nil { + t.Fatalf("generate: %v", err) + } + expiresAtStr, _ := genResult.Output["expires_at"].(string) + if expiresAtStr == "" { + t.Fatalf("expected expires_at in output, got %#v", genResult.Output) + } + expiresAt, err := time.Parse(time.RFC3339, expiresAtStr) + if err != nil { + t.Fatalf("parse expires_at: %v", err) + } + minExpected := before.Add(2 * time.Minute).Add(-1 * time.Second) + maxExpected := after.Add(2 * time.Minute).Add(1 * time.Second) + if expiresAt.Before(minExpected) || expiresAt.After(maxExpected) { + t.Fatalf("expected expires_at ~2 minutes from now (config ttl_minutes=2), got %v (window %v-%v)", expiresAt, minExpected, maxExpected) + } +} + // TestAuthChallengeVerifyConfig_AcceptsSigningSecret ensures the new // AuthChallengeVerifyConfig accepts BMW's signing_secret config field. func TestAuthChallengeVerifyConfig_AcceptsSigningSecret(t *testing.T) { diff --git a/plugin.contracts.json b/plugin.contracts.json index b3689f8..be6f404 100644 --- a/plugin.contracts.json +++ b/plugin.contracts.json @@ -107,7 +107,7 @@ "kind": "step", "type": "step.auth_challenge_generate", "mode": "strict", - "config": "workflow.plugins.auth.v1.EmptyConfig", + "config": "workflow.plugins.auth.v1.AuthChallengeGenerateConfig", "input": "workflow.plugins.auth.v1.ChallengeGenerateInput", "output": "workflow.plugins.auth.v1.ChallengeGenerateOutput" }, diff --git a/plugin.json b/plugin.json index 1003f2a..53f0f28 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "workflow-plugin-auth", - "version": "0.2.2", + "version": "0.2.3", "description": "Passwordless authentication plugin: WebAuthn/passkeys, TOTP, email magic links", "author": "GoCodeAlone", "license": "MIT", @@ -22,22 +22,22 @@ { "os": "linux", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-auth/releases/download/v0.2.2/workflow-plugin-auth_0.2.2_linux_amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-auth/releases/download/v0.2.3/workflow-plugin-auth_0.2.3_linux_amd64.tar.gz" }, { "os": "linux", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-auth/releases/download/v0.2.2/workflow-plugin-auth_0.2.2_linux_arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-auth/releases/download/v0.2.3/workflow-plugin-auth_0.2.3_linux_arm64.tar.gz" }, { "os": "darwin", "arch": "amd64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-auth/releases/download/v0.2.2/workflow-plugin-auth_0.2.2_darwin_amd64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-auth/releases/download/v0.2.3/workflow-plugin-auth_0.2.3_darwin_amd64.tar.gz" }, { "os": "darwin", "arch": "arm64", - "url": "https://github.com/GoCodeAlone/workflow-plugin-auth/releases/download/v0.2.2/workflow-plugin-auth_0.2.2_darwin_arm64.tar.gz" + "url": "https://github.com/GoCodeAlone/workflow-plugin-auth/releases/download/v0.2.3/workflow-plugin-auth_0.2.3_darwin_arm64.tar.gz" } ], "moduleTypes": [