diff --git a/api/spec/openapi.gen.go b/api/spec/openapi.gen.go index e83fadb91..1d60a6a32 100644 --- a/api/spec/openapi.gen.go +++ b/api/spec/openapi.gen.go @@ -19,157 +19,161 @@ import ( // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "/6ZEAnq61gAA", + "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", + "DwAA//9PHGT0J94AAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/cmd/vc-rest/startcmd/params.go b/cmd/vc-rest/startcmd/params.go index 31ce82c34..1a129ff20 100644 --- a/cmd/vc-rest/startcmd/params.go +++ b/cmd/vc-rest/startcmd/params.go @@ -268,6 +268,11 @@ const ( oidc4ciTransactionDataTTLFlagUsage = "OIDC4CI transaction data TTL. Defaults to 15m. " + commonEnvVarUsageText + oidc4ciTransactionDataTTLEnvKey + oidc4ciAckDataTTLFlagName = "vc-oidc4ci-ack-data-ttl" + oidc4ciAckDataTTLEnvKey = "VC_OIDC4CI_ACK_DATA_TTL" + oidc4ciAckDataTTLFlagUsage = "OIDC4CI ack data TTL. Defaults to 24h. " + + commonEnvVarUsageText + oidc4ciAckDataTTLEnvKey + oidc4ciAuthStateTTLFlagName = "vc-oidc4ci-auth-state-ttl" oidc4ciAuthStateTTLEnvKey = "VC_OIDC4CI_AUTH_STATE_TTL" oidc4ciAuthStateTTLFlagUsage = "OIDC4CI auth state data TTL. Defaults to 15m. " + @@ -379,6 +384,7 @@ const ( defaultOIDC4VPTransactionDataTTL = time.Hour defaultOIDC4VPNonceDataTTL = 15 * time.Minute defaultOIDC4CITransactionDataTTL = 15 * time.Minute + defaultOIDC4CIAckDataTTL = 24 * time.Hour defaultOIDC4CIAuthStateTTL = 15 * time.Minute defaultDataEncryptionKeyLength = 256 ) @@ -432,6 +438,7 @@ type transientDataParams struct { storeType string claimDataTTL int32 oidc4ciTransactionDataTTL int32 + oidc4ciAckDataTTL int32 oidc4ciAuthStateTTL int32 oidc4vpNonceStoreDataTTL int32 oidc4vpTransactionDataTTL int32 @@ -793,6 +800,13 @@ func getTransientDataParams(cmd *cobra.Command) (*transientDataParams, error) { if err != nil { return nil, err } + + oidc4ciAckDataTTL, err := getDuration( + cmd, oidc4ciAckDataTTLFlagName, oidc4ciAckDataTTLEnvKey, defaultOIDC4CIAckDataTTL) + if err != nil { + return nil, err + } + oidc4ciAuthStateTTL, err := getDuration( cmd, oidc4ciAuthStateTTLFlagName, oidc4ciAuthStateTTLEnvKey, defaultOIDC4CIAuthStateTTL) if err != nil { @@ -803,6 +817,7 @@ func getTransientDataParams(cmd *cobra.Command) (*transientDataParams, error) { storeType: transientDataStoreType, claimDataTTL: int32(claimDataTTL.Seconds()), oidc4ciTransactionDataTTL: int32(oidc4ciTransactionDataTTL.Seconds()), + oidc4ciAckDataTTL: int32(oidc4ciAckDataTTL.Seconds()), oidc4ciAuthStateTTL: int32(oidc4ciAuthStateTTL.Seconds()), oidc4vpReceivedClaimsDataTTL: int32(oidc4vpReceivedClaimsDataTTL.Seconds()), oidc4vpNonceStoreDataTTL: int32(oidc4vpNonceStoreDataTTL.Seconds()), @@ -1163,6 +1178,7 @@ func createFlags(startCmd *cobra.Command) { startCmd.Flags().StringP(oidc4vpTransactionDataTTLFlagName, "", "", oidc4vpTransactionDataTTLFlagUsage) startCmd.Flags().StringP(oidc4vpNonceTTLFlagName, "", "", oidc4vpNonceTTLFlagUsage) startCmd.Flags().StringP(oidc4ciTransactionDataTTLFlagName, "", "", oidc4ciTransactionDataTTLFlagUsage) + startCmd.Flags().StringP(oidc4ciAckDataTTLFlagName, "", "", oidc4ciAckDataTTLFlagUsage) startCmd.Flags().StringP(oidc4ciAuthStateTTLFlagName, "", "", oidc4ciAuthStateTTLFlagUsage) startCmd.Flags().StringP(otelServiceNameFlagName, "", "", otelServiceNameFlagUsage) diff --git a/cmd/vc-rest/startcmd/start.go b/cmd/vc-rest/startcmd/start.go index 84b1d16fc..bb8727f2e 100644 --- a/cmd/vc-rest/startcmd/start.go +++ b/cmd/vc-rest/startcmd/start.go @@ -111,6 +111,7 @@ import ( "github.com/trustbloc/vcs/pkg/storage/mongodb/vcstatusstore" "github.com/trustbloc/vcs/pkg/storage/redis" redisclient "github.com/trustbloc/vcs/pkg/storage/redis" + "github.com/trustbloc/vcs/pkg/storage/redis/ackstore" oidc4ciclaimdatastoreredis "github.com/trustbloc/vcs/pkg/storage/redis/oidc4ciclaimdatastore" oidc4cinoncestoreredis "github.com/trustbloc/vcs/pkg/storage/redis/oidc4cinoncestore" oidc4cistatestoreredis "github.com/trustbloc/vcs/pkg/storage/redis/oidc4cistatestore" @@ -642,6 +643,8 @@ func buildEchoHandler( return nil, fmt.Errorf("failed to instantiate oidc4ci transaction store: %w", err) } + ackStore := getAckStore(redisClient, conf.StartupParameters.transientDataParams.oidc4ciAckDataTTL) + oidc4ciClaimDataStore, err := getOIDC4CIClaimDataStore( conf.StartupParameters.transientDataParams.storeType, redisClientNoTracing, @@ -693,6 +696,12 @@ func buildEchoHandler( }, ) + ackService := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + EventSvc: eventSvc, + EventTopic: conf.StartupParameters.issuerEventTopic, + AckStore: ackStore, + ProfileSvc: issuerProfileSvc, + }) oidc4ciService, err = oidc4ci.NewService(&oidc4ci.Config{ TransactionStore: oidc4ciTransactionStore, ClaimDataStore: oidc4ciClaimDataStore, @@ -710,6 +719,7 @@ func buildEchoHandler( CryptoJWTSigner: vcCrypto, JSONSchemaValidator: jsonSchemaValidator, ClientAttestationService: clientAttestationService, + AckService: ackService, }) if err != nil { return nil, fmt.Errorf("failed to instantiate new oidc4ci service: %w", err) @@ -813,6 +823,7 @@ func buildEchoHandler( ClientManager: clientManagerService, ClientIDSchemeService: clientIDSchemeSvc, Tracer: conf.Tracer, + AckService: ackService, })) oidc4vpv1.RegisterHandlers(e, oidc4vpv1.NewController(&oidc4vpv1.Config{ @@ -1146,6 +1157,18 @@ func getOIDC4CITransactionStore( return store, nil } +func getAckStore( + redisClient *redis.Client, + oidc4ciTransactionDataTTL int32, +) *ackstore.Store { + if redisClient == nil { + logger.Warn("Redis client is not configured. Acknowledgement store will not be used") + return nil + } + + return ackstore.New(redisClient, oidc4ciTransactionDataTTL) +} + func createRequestObjectStore( repoType string, s3Region string, diff --git a/component/wallet-cli/pkg/walletrunner/models.go b/component/wallet-cli/pkg/walletrunner/models.go index 19e2d2ccf..d2dbe3f9f 100644 --- a/component/wallet-cli/pkg/walletrunner/models.go +++ b/component/wallet-cli/pkg/walletrunner/models.go @@ -103,4 +103,5 @@ type CredentialResponse struct { CNonceExpiresIn int `json:"c_nonce_expires_in,omitempty"` Credential interface{} `json:"credential"` Format verifiable2.OIDCFormat `json:"format"` + AckID *string `json:"ack_id"` } diff --git a/component/wallet-cli/pkg/walletrunner/wallet_runner.go b/component/wallet-cli/pkg/walletrunner/wallet_runner.go index 5aa463839..be6da2292 100644 --- a/component/wallet-cli/pkg/walletrunner/wallet_runner.go +++ b/component/wallet-cli/pkg/walletrunner/wallet_runner.go @@ -187,6 +187,7 @@ type PerfInfo struct { GetIssuerCredentialsOIDCConfig time.Duration `json:"vci_get_issuer_credentials_oidc_config"` GetAccessToken time.Duration `json:"vci_get_access_token"` GetCredential time.Duration `json:"vci_get_credential"` + CredentialsAck time.Duration `json:"vci_credentials_ack"` FetchRequestObject time.Duration `json:"vp_fetch_request_object"` VerifyAuthorizationRequest time.Duration `json:"vp_verify_authorization_request"` QueryCredentialFromWallet time.Duration `json:"vp_query_credential_from_wallet"` diff --git a/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4vci.go b/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4vci.go index 1a2a323fe..bde7b0230 100644 --- a/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4vci.go +++ b/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4vci.go @@ -232,7 +232,7 @@ func (s *Service) RunOIDC4VCI(config *OIDC4VCIConfig, hooks *Hooks) error { s.print("Getting credential") - vc, _, err := s.getCredential( + credResponse, _, err := s.getCredential( oidcIssuerCredentialConfig.CredentialEndpoint, config.CredentialType, config.CredentialFormat, @@ -243,6 +243,7 @@ func (s *Service) RunOIDC4VCI(config *OIDC4VCIConfig, hooks *Hooks) error { return fmt.Errorf("get credential: %w", err) } + vc := credResponse.Credential b, err = json.Marshal(vc) if err != nil { return fmt.Errorf("marshal vc: %w", err) @@ -271,6 +272,10 @@ func (s *Service) RunOIDC4VCI(config *OIDC4VCIConfig, hooks *Hooks) error { s.wallet.Close() } + if err = s.handleIssuanceAck(oidcIssuerCredentialConfig, credResponse); err != nil { + return err + } + return nil } @@ -392,7 +397,7 @@ func (s *Service) RunOIDC4CIWalletInitiated(config *OIDC4VCIConfig, hooks *Hooks s.token = token s.print("Getting credential") - vc, _, err := s.getCredential( + credResponse, _, err := s.getCredential( oidcIssuerCredentialConfig.CredentialEndpoint, config.CredentialType, config.CredentialFormat, @@ -403,6 +408,7 @@ func (s *Service) RunOIDC4CIWalletInitiated(config *OIDC4VCIConfig, hooks *Hooks return fmt.Errorf("get credential: %w", err) } + vc := credResponse.Credential b, err = json.Marshal(vc) if err != nil { return fmt.Errorf("marshal vc: %w", err) @@ -431,6 +437,10 @@ func (s *Service) RunOIDC4CIWalletInitiated(config *OIDC4VCIConfig, hooks *Hooks s.wallet.Close() } + if err = s.handleIssuanceAck(oidcIssuerCredentialConfig, credResponse); err != nil { + return err + } + return nil } @@ -520,7 +530,7 @@ func (s *Service) getCredential( credentialFormat, issuerURI string, beforeCredentialRequestOpts ...CredentialRequestOpt, -) (interface{}, time.Duration, error) { +) (*CredentialResponse, time.Duration, error) { credentialsRequestParamsOverride := &credentialRequestOpts{} for _, f := range beforeCredentialRequestOpts { f(credentialsRequestParamsOverride) @@ -559,7 +569,7 @@ func (s *Service) getCredential( } else if strings.Contains(didKeyID, "did:jwk") { res, err := jwk.New().Read(strings.Split(didKeyID, "#")[0]) if err != nil { - return "", 0, err + return nil, 0, err } signerKeyID = res.DIDDocument.VerificationMethod[0].ID @@ -628,7 +638,7 @@ func (s *Service) getCredential( return nil, finalDuration, fmt.Errorf("decode credential response: %w", err) } - return credentialResp.Credential, finalDuration, nil + return &credentialResp, finalDuration, nil } func (s *Service) print( diff --git a/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4vci_pre_auth.go b/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4vci_pre_auth.go index f4c71bb89..782547ac9 100644 --- a/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4vci_pre_auth.go +++ b/component/wallet-cli/pkg/walletrunner/wallet_runner_oidc4vci_pre_auth.go @@ -8,6 +8,8 @@ package walletrunner import ( "bufio" + "bytes" + "context" "encoding/json" "fmt" "io" @@ -22,6 +24,7 @@ import ( "golang.org/x/oauth2" "github.com/trustbloc/vcs/component/wallet-cli/pkg/credentialoffer" + issuerv1 "github.com/trustbloc/vcs/pkg/restapi/v1/issuer" "github.com/trustbloc/vcs/pkg/restapi/v1/oidc4ci" ) @@ -113,7 +116,7 @@ func (s *Service) RunOIDC4CIPreAuth(config *OIDC4VCIConfig, hooks *Hooks) (*veri s.print("Getting credential") startTime = time.Now() - vc, vcsDuration, err := s.getCredential( + credResponse, vcsDuration, err := s.getCredential( oidcIssuerCredentialConfig.CredentialEndpoint, config.CredentialType, config.CredentialFormat, @@ -126,6 +129,7 @@ func (s *Service) RunOIDC4CIPreAuth(config *OIDC4VCIConfig, hooks *Hooks) (*veri s.perfInfo.VcsCIFlowDuration += vcsDuration s.perfInfo.GetCredential = time.Since(startTime) + vc := credResponse.Credential b, err := json.Marshal(vc) if err != nil { return nil, fmt.Errorf("marshal vc: %w", err) @@ -152,5 +156,58 @@ func (s *Service) RunOIDC4CIPreAuth(config *OIDC4VCIConfig, hooks *Hooks) (*veri s.wallet.Close() } + startTime = time.Now() + if err = s.handleIssuanceAck(oidcIssuerCredentialConfig, credResponse); err != nil { + return nil, err + } + s.perfInfo.CredentialsAck = time.Since(startTime) + return vcParsed, nil } + +func (s *Service) handleIssuanceAck( + wellKnown *issuerv1.WellKnownOpenIDIssuerConfiguration, + credResponse *CredentialResponse, +) error { + if wellKnown == nil || credResponse == nil { + return nil + } + + if wellKnown.CredentialAckEndpoint == "" || lo.FromPtr(credResponse.AckID) == "" { + return nil + } + + s.print("Sending wallet ACK") + + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, s.httpClient) + httpClient := s.oauthClient.Client(ctx, s.token) + + b, err := json.Marshal(oidc4ci.AckRequest{ + Credentials: []oidc4ci.AcpRequestItem{ + { + AckId: *credResponse.AckID, + ErrorDescription: nil, + Status: "success", + IssuerIdentifier: &wellKnown.CredentialIssuer, + }, + }, + }) + if err != nil { + return err + } + + resp, err := httpClient.Post(wellKnown.CredentialAckEndpoint, "application/json", bytes.NewBuffer(b)) + if err != nil { + return err + } + + s.print(fmt.Sprintf("Wallet ACK sent with status code %v", resp.StatusCode)) + + b, _ = io.ReadAll(resp.Body) // nolint + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("expected to receive status code %d but got status code %d with response body %s", + http.StatusNoContent, resp.StatusCode, string(b)) + } + + return nil +} diff --git a/docs/v1/openapi.yaml b/docs/v1/openapi.yaml index 2c237b497..4b0a082a1 100644 --- a/docs/v1/openapi.yaml +++ b/docs/v1/openapi.yaml @@ -805,6 +805,28 @@ paths: schema: $ref: '#/components/schemas/CredentialRequest' parameters: [] + /oidc/acknowledgement: + post: + summary: OIDC Acknowledgement + tags: + - oidc4ci + responses: + '204': + description: Ok + '400': + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/AckErrorResponse' + operationId: oidc-acknowledgement + description: Issues credentials in exchange for an authorization token. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AckRequest' + parameters: [ ] components: schemas: HealthCheckResponse: @@ -860,6 +882,9 @@ components: wallet_initiated_auth_flow_supported: type: boolean description: JSON Boolean indicating whether the issuer profile supports wallet initiated flow in OIDC4CI. The default is false. + credential_ack_endpoint: + type: string + description: URL of the acknowledgement endpoint. required: - authorization_endpoint - token_endpoint @@ -868,6 +893,7 @@ components: - grant_types_supported - wallet_initiated_auth_flow_supported - pre-authorized_grant_anonymous_access_supported + - credential_ack_endpoint WellKnownOpenIDIssuerConfiguration: title: WellKnownOpenIDIssuerConfiguration response x-tags: @@ -923,6 +949,9 @@ components: items: type: string description: JSON array containing a list of client authentication methods supported by this token endpoint. Default is "none". + credential_ack_endpoint: + type: string + description: URL of the acknowledgement endpoint. required: - authorization_endpoint - token_endpoint @@ -935,6 +964,7 @@ components: - credential_endpoint - credentials_supported - token_endpoint_auth_methods_supported + - credential_ack_endpoint description: WellKnownOpenIDIssuerConfiguration represents the OIDC Configuration response for cases when VCS serves as IDP. CredentialIssuanceHistoryData: title: CredentialIssuanceHistory response @@ -1756,10 +1786,14 @@ components: audienceClaim: type: string description: The "aud" claim received from the client. + hashed_token: + type: string + description: Hashed token received from the client. required: - tx_id - types - audienceClaim + - hashed_token PrepareCredentialResult: title: PrepareCredentialResult x-tags: @@ -1777,6 +1811,9 @@ components: oidc_format: type: string description: OIDC credential format + ack_id: + type: string + description: String identifying an issued Credential that the Wallet includes in the acknowledgement request. retry: type: boolean description: TRUE if claim data is not yet available in the issuer OP server. This will indicate VCS OIDC to issue acceptance_token instead of credential response (Deferred Credential flow). @@ -1819,6 +1856,50 @@ components: required: - proof_type - jwt + AckRequest: + title: AckRequest + x-tags: + - oidc4ci + type: object + description: Ack response. + properties: + credentials: + type: array + items: + $ref: '#/components/schemas/AcpRequestItem' + required: + - credentials + AcpRequestItem: + type: object + description: AcpRequestItem + properties: + ack_id: + type: string + description: Ack ID. + status: + type: string + description: Ack Status. + issuer_identifier: + type: string + description: Optional issuer identifier. + error_description: + type: string + description: error description. + required: + - ack_id + - status + AckErrorResponse: + title: AckResponse + x-tags: + - oidc4ci + type: object + description: Ack response. + properties: + error: + type: string + description: Error description. + required: + - error CredentialResponse: title: CredentialResponse x-tags: @@ -1842,6 +1923,9 @@ components: c_nonce_expires_in: type: integer description: JSON integer denoting the lifetime in seconds of the c_nonce. + ack_id: + type: string + description: String identifying an issued Credential that the Wallet includes in the acknowledgement request. required: - format - credential diff --git a/pkg/event/spi/spi.go b/pkg/event/spi/spi.go index f7d2e400e..d4020e7ba 100644 --- a/pkg/event/spi/spi.go +++ b/pkg/event/spi/spi.go @@ -49,6 +49,10 @@ const ( IssuerOIDCInteractionAuthorizationCodeStored EventType = "issuer.oidc-interaction-authorization-code-stored.v1" //nolint IssuerOIDCInteractionAuthorizationCodeExchanged EventType = "issuer.oidc-interaction-authorization-code-exchanged.v1" //nolint IssuerOIDCInteractionFailed EventType = "issuer.oidc-interaction-failed.v1" + IssuerOIDCInteractionAckSucceeded EventType = "issuer.oidc-interaction-ack-succeeded.v1" + IssuerOIDCInteractionAckFailed EventType = "issuer.oidc-interaction-ack-failed.v1" + IssuerOIDCInteractionAckRejected EventType = "issuer.oidc-interaction-ack-rejected.v1" + IssuerOIDCInteractionAckExpired EventType = "issuer.oidc-interaction-ack-expired.v1" CredentialStatusStatusUpdated EventType = "issuer.credential-status-updated.v1" //nolint:gosec ) diff --git a/pkg/restapi/v1/issuer/controller.go b/pkg/restapi/v1/issuer/controller.go index bf1ad8853..cd048da6a 100644 --- a/pkg/restapi/v1/issuer/controller.go +++ b/pkg/restapi/v1/issuer/controller.go @@ -433,11 +433,10 @@ func (c *Controller) InitiateCredentialIssuance(e echo.Context, profileID, profi // OpenidConfigV2 request openid configuration for issuer. // TODO to remove // GET /oidc/idp/{profileID}/{profileVersion}/.well-known/openid-configuration. func (c *Controller) OpenidConfigV2(ctx echo.Context, profileID, profileVersion string) error { - return util.WriteOutput(ctx)(c.getOpenIDConfig(profileID, profileVersion)) + return util.WriteOutput(ctx)(c.GetOpenIDConfig(profileID, profileVersion)) } -// TODO to remove -func (c *Controller) getOpenIDConfig(profileID, profileVersion string) (*WellKnownOpenIDIssuerConfiguration, error) { +func (c *Controller) GetOpenIDConfig(profileID, profileVersion string) (*WellKnownOpenIDIssuerConfiguration, error) { host := c.externalHostURL if !strings.HasSuffix(host, "/") { host += "/" @@ -448,7 +447,8 @@ func (c *Controller) getOpenIDConfig(profileID, profileVersion string) (*WellKno ResponseTypesSupported: []string{ "code", }, - TokenEndpoint: fmt.Sprintf("%soidc/token", host), + TokenEndpoint: fmt.Sprintf("%soidc/token", host), + CredentialAckEndpoint: fmt.Sprintf("%soidc/acknowledgement", host), } profile, err := c.profileSvc.GetProfile(profileID, profileVersion) @@ -729,6 +729,7 @@ func (c *Controller) PrepareCredential(e echo.Context) error { CredentialFormat: vcFormat, DID: lo.FromPtr(body.Did), AudienceClaim: body.AudienceClaim, + HashedToken: body.HashedToken, }, ) @@ -766,6 +767,7 @@ func (c *Controller) PrepareCredential(e echo.Context) error { Format: string(result.Format), OidcFormat: string(result.OidcFormat), Retry: result.Retry, + AckId: result.AckID, }, nil) } diff --git a/pkg/restapi/v1/issuer/controller_test.go b/pkg/restapi/v1/issuer/controller_test.go index 87fec10ec..6a05802b2 100644 --- a/pkg/restapi/v1/issuer/controller_test.go +++ b/pkg/restapi/v1/issuer/controller_test.go @@ -1782,6 +1782,20 @@ func Test_getCredentialSubjects(t *testing.T) { }) } +func TestGetConfig(t *testing.T) { + profileSvc := NewMockProfileService(gomock.NewController(t)) + + ctr := NewController(&Config{ + ProfileSvc: profileSvc, + }) + + profileSvc.EXPECT().GetProfile("12345", "v0.1"). + Return(&profileapi.Issuer{}, nil) + resp, err := ctr.GetOpenIDConfig("12345", "v0.1") + assert.NoError(t, err) + assert.Equal(t, "/oidc/acknowledgement", resp.CredentialAckEndpoint) +} + func Test_sendFailedEvent(t *testing.T) { t.Run("marshal error", func(t *testing.T) { c := NewController(&Config{}) diff --git a/pkg/restapi/v1/issuer/openapi.gen.go b/pkg/restapi/v1/issuer/openapi.gen.go index 8531190e5..8d1c7e9f8 100644 --- a/pkg/restapi/v1/issuer/openapi.gen.go +++ b/pkg/restapi/v1/issuer/openapi.gen.go @@ -231,6 +231,9 @@ type PrepareCredential struct { // Format of the credential being issued. Format *string `json:"format,omitempty"` + // Hashed token received from the client. + HashedToken string `json:"hashed_token"` + // Transaction ID. TxId string `json:"tx_id"` @@ -240,6 +243,8 @@ type PrepareCredential struct { // Model for Prepare Credential response. type PrepareCredentialResult struct { + // String identifying an issued Credential that the Wallet includes in the acknowledgement request. + AckId *string `json:"ack_id,omitempty"` Credential interface{} `json:"credential"` // Format of issued credential. @@ -316,6 +321,9 @@ type WellKnownOpenIDConfiguration struct { // URL of the OP's OAuth 2.0 Authorization Endpoint. AuthorizationEndpoint string `json:"authorization_endpoint"` + // URL of the acknowledgement endpoint. + CredentialAckEndpoint string `json:"credential_ack_endpoint"` + // JSON array containing a list of the OAuth 2.0 Grant Type values that this OP supports. GrantTypesSupported []string `json:"grant_types_supported"` @@ -346,6 +354,9 @@ type WellKnownOpenIDIssuerConfiguration struct { // URL of the Credential Issuer's Batch Credential Endpoint. This URL MUST use the https scheme and MAY contain port, path and query parameter components. If omitted, the Credential Issuer does not support the Batch Credential Endpoint. BatchCredentialEndpoint *string `json:"batch_credential_endpoint,omitempty"` + // URL of the acknowledgement endpoint. + CredentialAckEndpoint string `json:"credential_ack_endpoint"` + // URL of the Credential Issuer's Credential Endpoint. This URL MUST use the https scheme and MAY contain port, path and query parameter components. CredentialEndpoint string `json:"credential_endpoint"` diff --git a/pkg/restapi/v1/mw/api_key_auth.go b/pkg/restapi/v1/mw/api_key_auth.go index 646dccf7d..c09b039bd 100644 --- a/pkg/restapi/v1/mw/api_key_auth.go +++ b/pkg/restapi/v1/mw/api_key_auth.go @@ -27,6 +27,7 @@ const ( oidcRedirect = "/oidc/redirect" oidcPresent = "/oidc/present" oidcToken = "/oidc/token" + oidcAck = "/oidc/acknowledgement" oidcCredential = "/oidc/credential" oidcCredentialWellKnown = "/.well-known/openid-credential-issuer" issuedCredentialsHistory = "/issued-credentials" @@ -76,6 +77,7 @@ func APIKeyAuth(apiKey string) echo.MiddlewareFunc { //nolint:gocognit strings.HasPrefix(currentPath, oidcRedirect) || strings.HasPrefix(currentPath, oidcPresent) || strings.HasPrefix(currentPath, oidcToken) || + strings.HasPrefix(currentPath, oidcAck) || strings.HasPrefix(currentPath, oidcCredential) || strings.HasSuffix(currentPath, issuedCredentialsHistory) || strings.HasSuffix(currentPath, oidcCredentialWellKnown) || diff --git a/pkg/restapi/v1/oidc4ci/controller.go b/pkg/restapi/v1/oidc4ci/controller.go index 8fbe63341..90e678697 100644 --- a/pkg/restapi/v1/oidc4ci/controller.go +++ b/pkg/restapi/v1/oidc4ci/controller.go @@ -5,14 +5,16 @@ SPDX-License-Identifier: Apache-2.0 */ //go:generate oapi-codegen --config=openapi.cfg.yaml ../../../../docs/v1/openapi.yaml -//go:generate mockgen -destination controller_mocks_test.go -self_package mocks -package oidc4ci_test . StateStore,OAuth2Provider,IssuerInteractionClient,HTTPClient,ClientManager,ProfileService +//go:generate mockgen -destination controller_mocks_test.go -self_package mocks -package oidc4ci_test . StateStore,OAuth2Provider,IssuerInteractionClient,HTTPClient,ClientManager,ProfileService,AckService package oidc4ci import ( "context" "crypto/rand" + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -99,6 +101,13 @@ type ProfileService interface { GetProfile(profileID profileapi.ID, profileVersion profileapi.Version) (*profileapi.Issuer, error) } +type AckService interface { + Ack( + ctx context.Context, + req oidc4ci.AckRemote, + ) error +} + // Config holds configuration options for Controller. type Config struct { OAuth2Provider OAuth2Provider @@ -112,6 +121,7 @@ type Config struct { Tracer trace.Tracer IssuerVCSPublicHost string ExternalHostURL string + AckService AckService } // Controller for OIDC credential issuance API. @@ -127,6 +137,7 @@ type Controller struct { tracer trace.Tracer issuerVCSPublicHost string internalHostURL string + ackService AckService } // NewController creates a new Controller instance. @@ -143,6 +154,7 @@ func NewController(config *Config) *Controller { tracer: config.Tracer, issuerVCSPublicHost: config.IssuerVCSPublicHost, internalHostURL: config.ExternalHostURL, + ackService: config.AckService, } } @@ -563,6 +575,52 @@ func mustGenerateNonce() string { return base64.URLEncoding.EncodeToString(b) } +// OidcAcknowledgement handles OIDC acknowledgement request (POST /oidc/acknowledgement). +func (c *Controller) OidcAcknowledgement(e echo.Context) error { + req := e.Request() + + // ctx, span := c.tracer.Start(req.Context(), "OidcAcknowledgement") + // defer span.End() + + var body AckRequest + if err := e.Bind(&body); err != nil { + return err + } + + token := fosite.AccessTokenFromRequest(req) + if token == "" { + return resterr.NewOIDCError(invalidTokenOIDCErr, errors.New("missing access token")) + } + + // for now we dont need to introspect token as it can be expired. + // todo: once new we have new spec add logic with token + // _, _, err := c.oauth2Provider.IntrospectToken(ctx, token, fosite.AccessToken, new(fosite.DefaultSession)) + // if err != nil { + // return resterr.NewOIDCError(invalidTokenOIDCErr, fmt.Errorf("introspect token: %w", err)) + // } + + var finalErr error + for _, r := range body.Credentials { + if err := c.ackService.Ack(req.Context(), oidc4ci.AckRemote{ + HashedToken: hashToken(token), + ID: r.AckId, + Status: r.Status, + ErrorText: lo.FromPtr(r.ErrorDescription), + IssuerIdentifier: lo.FromPtr(r.IssuerIdentifier), + }); err != nil { + finalErr = errors.Join(finalErr, err) + } + } + + if finalErr != nil { + return apiUtil.WriteOutputWithCode(http.StatusBadRequest, e)(AckErrorResponse{ + Error: finalErr.Error(), + }, nil) + } + + return e.NoContent(http.StatusNoContent) +} + // OidcCredential handles OIDC credential request (POST /oidc/credential). func (c *Controller) OidcCredential(e echo.Context) error { req := e.Request() @@ -615,6 +673,7 @@ func (c *Controller) OidcCredential(e echo.Context) error { Types: credentialRequest.Types, Format: credentialRequest.Format, AudienceClaim: claims.Audience, + HashedToken: hashToken(token), }, ) if err != nil { @@ -661,6 +720,7 @@ func (c *Controller) OidcCredential(e echo.Context) error { Format: result.OidcFormat, CNonce: lo.ToPtr(nonce), CNonceExpiresIn: lo.ToPtr(int(cNonceTTL.Seconds())), + AckId: result.AckId, }, nil) } @@ -982,3 +1042,8 @@ func parseInteractionError(reader io.Reader) error { return &e } + +func hashToken(text string) string { + hash := sha256.Sum256([]byte(text)) + return hex.EncodeToString(hash[:]) +} diff --git a/pkg/restapi/v1/oidc4ci/controller_test.go b/pkg/restapi/v1/oidc4ci/controller_test.go index 24d477818..e069cc1df 100644 --- a/pkg/restapi/v1/oidc4ci/controller_test.go +++ b/pkg/restapi/v1/oidc4ci/controller_test.go @@ -12,6 +12,8 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -2103,6 +2105,104 @@ func TestController_OidcPreAuthorize(t *testing.T) { } } +func TestController_Ack(t *testing.T) { + hh := func(text string) string { + hash := sha256.Sum256([]byte(text)) + return hex.EncodeToString(hash[:]) + } + + t.Run("success", func(t *testing.T) { + mockOAuthProvider := NewMockOAuth2Provider(gomock.NewController(t)) + + ackMock := NewMockAckService(gomock.NewController(t)) + mockOAuthProvider.EXPECT().NewAccessRequest(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&fosite.AccessRequest{}, nil).AnyTimes() + controller := oidc4ci.NewController(&oidc4ci.Config{ + OAuth2Provider: mockOAuthProvider, + AckService: ackMock, + Tracer: trace.NewNoopTracerProvider().Tracer(""), + }) + + expectedToken := "xxxx" + + ackMock.EXPECT().Ack(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, remote oidc4cisrv.AckRemote) error { + assert.Equal(t, hh(expectedToken), remote.HashedToken) + assert.Equal(t, "tx_id", remote.ID) + assert.Equal(t, "status", remote.Status) + assert.Equal(t, "err_txt", remote.ErrorText) + + return nil + }) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer([]byte(`{ + "credentials" : [{"ack_id" : "tx_id", "status" : "status", "error_description" : "err_txt"}] + }`))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("Authorization", "Bearer xxxx") + rec := httptest.NewRecorder() + + err := controller.OidcAcknowledgement(echo.New().NewContext(req, rec)) + assert.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + }) + + t.Run("ack err", func(t *testing.T) { + mockOAuthProvider := NewMockOAuth2Provider(gomock.NewController(t)) + + ackMock := NewMockAckService(gomock.NewController(t)) + mockOAuthProvider.EXPECT().NewAccessRequest(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&fosite.AccessRequest{}, nil).AnyTimes() + controller := oidc4ci.NewController(&oidc4ci.Config{ + OAuth2Provider: mockOAuthProvider, + AckService: ackMock, + Tracer: trace.NewNoopTracerProvider().Tracer(""), + }) + + ackMock.EXPECT().Ack(gomock.Any(), gomock.Any()). + Return(errors.New("some error")) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer([]byte(`{ + "credentials" : [{"ack_id" : "tx_id", "status" : "status", "error_description" : "err_txt"}] + }`))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("Authorization", "Bearer xxxx") + + rec := httptest.NewRecorder() + + err := controller.OidcAcknowledgement(echo.New().NewContext(req, rec)) + assert.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, rec.Code) + + var bd oidc4ci.AckErrorResponse + b, _ := io.ReadAll(rec.Body) + + assert.NoError(t, json.Unmarshal(b, &bd)) + assert.Equal(t, "some error", bd.Error) + }) + + t.Run("token err 2", func(t *testing.T) { + mockOAuthProvider := NewMockOAuth2Provider(gomock.NewController(t)) + + mockOAuthProvider.EXPECT().NewAccessRequest(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&fosite.AccessRequest{}, nil).AnyTimes() + controller := oidc4ci.NewController(&oidc4ci.Config{ + OAuth2Provider: mockOAuthProvider, + Tracer: trace.NewNoopTracerProvider().Tracer(""), + }) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer([]byte(`{ + "credentials" : [{"ack_id" : "tx_id", "status" : "status", "error_description" : "err_txt"}] + }`))) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + + rec := httptest.NewRecorder() + + err := controller.OidcAcknowledgement(echo.New().NewContext(req, rec)) + assert.ErrorContains(t, err, "missing access token") + }) +} + func TestController_OidcRegisterClient(t *testing.T) { mockClientManager := NewMockClientManager(gomock.NewController(t)) mockProfileService := NewMockProfileService(gomock.NewController(t)) diff --git a/pkg/restapi/v1/oidc4ci/openapi.gen.go b/pkg/restapi/v1/oidc4ci/openapi.gen.go index f5c263f4e..7715bb662 100644 --- a/pkg/restapi/v1/oidc4ci/openapi.gen.go +++ b/pkg/restapi/v1/oidc4ci/openapi.gen.go @@ -35,6 +35,32 @@ type AccessTokenResponse struct { TokenType string `json:"token_type"` } +// Ack response. +type AckErrorResponse struct { + // Error description. + Error string `json:"error"` +} + +// Ack response. +type AckRequest struct { + Credentials []AcpRequestItem `json:"credentials"` +} + +// AcpRequestItem +type AcpRequestItem struct { + // Ack ID. + AckId string `json:"ack_id"` + + // error description. + ErrorDescription *string `json:"error_description,omitempty"` + + // Optional issuer identifier. + IssuerIdentifier *string `json:"issuer_identifier,omitempty"` + + // Ack Status. + Status string `json:"status"` +} + // Model for OIDC Credential request. type CredentialRequest struct { // Format of the credential being issued. @@ -50,6 +76,9 @@ type CredentialResponse struct { // A JSON string containing a token subsequently used to obtain a Credential. MUST be present when credential is not returned. AcceptanceToken *string `json:"acceptance_token,omitempty"` + // String identifying an issued Credential that the Wallet includes in the acknowledgement request. + AckId *string `json:"ack_id,omitempty"` + // JSON string containing a nonce to be used to create a proof of possession of key material when requesting a Credential. CNonce *string `json:"c_nonce,omitempty"` @@ -196,6 +225,9 @@ type RegisterOAuthClientResponse struct { TosUri *string `json:"tos_uri,omitempty"` } +// OidcAcknowledgementJSONBody defines parameters for OidcAcknowledgement. +type OidcAcknowledgementJSONBody = AckRequest + // OidcAuthorizeParams defines parameters for OidcAuthorize. type OidcAuthorizeParams struct { // Value MUST be set to "code". @@ -250,6 +282,9 @@ type OidcRedirectParams struct { // OidcRegisterClientJSONBody defines parameters for OidcRegisterClient. type OidcRegisterClientJSONBody = RegisterOAuthClientRequest +// OidcAcknowledgementJSONRequestBody defines body for OidcAcknowledgement for application/json ContentType. +type OidcAcknowledgementJSONRequestBody = OidcAcknowledgementJSONBody + // OidcCredentialJSONRequestBody defines body for OidcCredential for application/json ContentType. type OidcCredentialJSONRequestBody = OidcCredentialJSONBody @@ -258,6 +293,9 @@ type OidcRegisterClientJSONRequestBody = OidcRegisterClientJSONBody // ServerInterface represents all server handlers. type ServerInterface interface { + // OIDC Acknowledgement + // (POST /oidc/acknowledgement) + OidcAcknowledgement(ctx echo.Context) error // OIDC Authorization Request // (GET /oidc/authorize) OidcAuthorize(ctx echo.Context, params OidcAuthorizeParams) error @@ -283,6 +321,15 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } +// OidcAcknowledgement converts echo context to params. +func (w *ServerInterfaceWrapper) OidcAcknowledgement(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshalled arguments + err = w.Handler.OidcAcknowledgement(ctx) + return err +} + // OidcAuthorize converts echo context to params. func (w *ServerInterfaceWrapper) OidcAuthorize(ctx echo.Context) error { var err error @@ -482,6 +529,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL Handler: si, } + router.POST(baseURL+"/oidc/acknowledgement", wrapper.OidcAcknowledgement) router.GET(baseURL+"/oidc/authorize", wrapper.OidcAuthorize) router.POST(baseURL+"/oidc/credential", wrapper.OidcCredential) router.POST(baseURL+"/oidc/par", wrapper.OidcPushedAuthorizationRequest) diff --git a/pkg/restapi/v1/util/bind.go b/pkg/restapi/v1/util/bind.go index 410e2b670..139082844 100644 --- a/pkg/restapi/v1/util/bind.go +++ b/pkg/restapi/v1/util/bind.go @@ -27,6 +27,10 @@ func ReadBody(ctx echo.Context, body interface{}) error { } func WriteOutput(ctx echo.Context) func(output interface{}, err error) error { + return WriteOutputWithCode(http.StatusOK, ctx) +} + +func WriteOutputWithCode(code int, ctx echo.Context) func(output interface{}, err error) error { return func(output interface{}, err error) error { if err != nil { return err @@ -37,7 +41,7 @@ func WriteOutput(ctx echo.Context) func(output interface{}, err error) error { return err } - return ctx.JSONBlob(http.StatusOK, b) + return ctx.JSONBlob(code, b) } } diff --git a/pkg/service/oidc4ci/api.go b/pkg/service/oidc4ci/api.go index bba025eb9..e0a794248 100644 --- a/pkg/service/oidc4ci/api.go +++ b/pkg/service/oidc4ci/api.go @@ -8,6 +8,7 @@ package oidc4ci import ( "context" + "errors" "net/url" "time" @@ -47,6 +48,7 @@ const ( const ( ContentTypeApplicationJSON InitiateIssuanceResponseContentType = echo.MIMEApplicationJSONCharsetUTF8 ContentTypeApplicationJWT InitiateIssuanceResponseContentType = "application/jwt" + issuerIdentifierParts = 2 ) // ClaimData represents user claims in pre-auth code flow. @@ -164,6 +166,7 @@ type PrepareCredential struct { CredentialFormat vcsverifiable.Format DID string AudienceClaim string + HashedToken string } type PrepareCredentialResult struct { @@ -175,6 +178,7 @@ type PrepareCredentialResult struct { EnforceStrictValidation bool OidcFormat vcsverifiable.OIDCFormat CredentialTemplate *profileapi.CredentialTemplate + AckID *string } type InsertOptions struct { @@ -265,3 +269,22 @@ type ServiceInterface interface { ) (*Transaction, error) PrepareCredential(ctx context.Context, req *PrepareCredential) (*PrepareCredentialResult, error) } + +type Ack struct { + HashedToken string `json:"hashed_token"` + ProfileID string `json:"profile_id"` + ProfileVersion string `json:"profile_version"` + TxID TxID `json:"tx_id"` + WebHookURL string `json:"webhook_url"` + OrgID string `json:"org_id"` +} + +type AckRemote struct { + HashedToken string `json:"hashed_token"` + ID string `json:"id"` + Status string `json:"status"` + ErrorText string `json:"error_text"` + IssuerIdentifier string `json:"issuer_identifier"` +} + +var ErrDataNotFound = errors.New("data not found") diff --git a/pkg/service/oidc4ci/oidc4ci_acknowledgement.go b/pkg/service/oidc4ci/oidc4ci_acknowledgement.go new file mode 100644 index 000000000..f53cbd321 --- /dev/null +++ b/pkg/service/oidc4ci/oidc4ci_acknowledgement.go @@ -0,0 +1,170 @@ +/* +Copyright SecureKey Technologies Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package oidc4ci + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/trustbloc/vcs/pkg/event/spi" +) + +type AckService struct { + cfg *AckServiceConfig +} + +type AckServiceConfig struct { + AckStore ackStore + EventSvc eventService + EventTopic string + ProfileSvc profileService +} + +func NewAckService( + cfg *AckServiceConfig, +) *AckService { + return &AckService{ + cfg: cfg, + } +} + +// CreateAck creates an acknowledgement. +func (s *AckService) CreateAck( + ctx context.Context, + ack *Ack, +) (*string, error) { + if s.cfg.AckStore == nil { + return nil, nil //nolint:nilnil + } + + id, err := s.cfg.AckStore.Create(ctx, ack) + if err != nil { + return nil, err + } + + return &id, nil +} + +func (s *AckService) HandleAckNotFound( + ctx context.Context, + req AckRemote, +) error { + if req.IssuerIdentifier == "" { + return errors.New("issuer identifier is empty and ack not found") + } + + parts := strings.Split(req.IssuerIdentifier, "/") + if len(parts) < issuerIdentifierParts { + return errors.New("invalid issuer identifier. expected format https://xxx/{profileID}/{profileVersion}") + } + + profileID := parts[len(parts)-2] + profileVersion := parts[len(parts)-1] + + profile, err := s.cfg.ProfileSvc.GetProfile(profileID, profileVersion) + if err != nil { + return err + } + + eventPayload := &EventPayload{ + WebHook: profile.WebHook, + ProfileID: profile.ID, + ProfileVersion: profile.Version, + OrgID: profile.OrganizationID, + } + + if req.ErrorText != "" { + eventPayload.ErrorComponent = "wallet" + eventPayload.Error = req.ErrorText + } + + err = s.sendEvent(ctx, spi.IssuerOIDCInteractionAckExpired, TxID(req.ID), eventPayload) + if err != nil { + return err + } + + return errors.New("ack expired") +} + +// Ack acknowledges the interaction. +func (s *AckService) Ack( + ctx context.Context, + req AckRemote, +) error { + if s.cfg.AckStore == nil { + return nil + } + + ack, err := s.cfg.AckStore.Get(ctx, req.ID) + if err != nil { + if errors.Is(err, ErrDataNotFound) { + return s.HandleAckNotFound(ctx, req) + } + return err + } + + if ack.HashedToken != req.HashedToken { + return errors.New("invalid token") + } + + eventPayload := &EventPayload{ + WebHook: ack.WebHookURL, + ProfileID: ack.ProfileID, + ProfileVersion: ack.ProfileVersion, + OrgID: ack.OrgID, + } + + if req.ErrorText != "" { + eventPayload.ErrorComponent = "wallet" + eventPayload.Error = req.ErrorText + } + + targetEvent, err := s.AckEventMap(req.Status) + if err != nil { + return err + } + + err = s.sendEvent(ctx, targetEvent, ack.TxID, eventPayload) + if err != nil { + return err + } + + if err = s.cfg.AckStore.Delete(ctx, req.ID); err != nil { // not critical + logger.Errorc(ctx, fmt.Sprintf("failed to delete ack with id[%s]: %s", req.ID, err.Error())) + } + + return nil +} + +func (s *AckService) sendEvent( + ctx context.Context, + eventType spi.EventType, + transactionID TxID, + ep *EventPayload, +) error { + event, err := createEvent(eventType, transactionID, ep) + if err != nil { + return err + } + + return s.cfg.EventSvc.Publish(ctx, s.cfg.EventTopic, event) +} + +func (s *AckService) AckEventMap(status string) (spi.EventType, error) { + switch strings.ToLower(status) { + case "success": + return spi.IssuerOIDCInteractionAckSucceeded, nil + case "failure": + return spi.IssuerOIDCInteractionAckFailed, nil + case "rejected": + return spi.IssuerOIDCInteractionAckRejected, nil + } + + return spi.IssuerOIDCInteractionAckFailed, fmt.Errorf("invalid status: %s", status) +} diff --git a/pkg/service/oidc4ci/oidc4ci_acknowledgement_test.go b/pkg/service/oidc4ci/oidc4ci_acknowledgement_test.go new file mode 100644 index 000000000..ff096e4c9 --- /dev/null +++ b/pkg/service/oidc4ci/oidc4ci_acknowledgement_test.go @@ -0,0 +1,450 @@ +/* +Copyright SecureKey Technologies Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package oidc4ci_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/trustbloc/vcs/pkg/event/spi" + "github.com/trustbloc/vcs/pkg/profile" + "github.com/trustbloc/vcs/pkg/service/oidc4ci" +) + +func TestCreateAck(t *testing.T) { + t.Run("missing store", func(t *testing.T) { + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{}) + id, err := srv.CreateAck(context.TODO(), &oidc4ci.Ack{}) + assert.NoError(t, err) + assert.Nil(t, id) + }) + + t.Run("success", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + }) + + item := &oidc4ci.Ack{} + store.EXPECT().Create(gomock.Any(), item).Return("id", nil) + id, err := srv.CreateAck(context.TODO(), item) + + assert.NoError(t, err) + assert.Equal(t, "id", *id) + }) + + t.Run("store err", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + }) + + item := &oidc4ci.Ack{} + store.EXPECT().Create(gomock.Any(), item).Return("", errors.New("some err")) + id, err := srv.CreateAck(context.TODO(), item) + + assert.Nil(t, id) + assert.ErrorContains(t, err, "some err") + }) +} + +func TestAckFallback(t *testing.T) { + t.Run("success", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + eventSvc := NewMockEventService(gomock.NewController(t)) + profileSvc := NewMockProfileService(gomock.NewController(t)) + + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + EventSvc: eventSvc, + ProfileSvc: profileSvc, + }) + + profileSvc.EXPECT().GetProfile("some_issuer", "v1.0"). + Return(&profile.Issuer{ + WebHook: "1234", + ID: "4567", + Version: "2222", + OrganizationID: "1111", + }, nil) + + store.EXPECT().Get(gomock.Any(), "123").Return(nil, oidc4ci.ErrDataNotFound) + eventSvc.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, _ string, events ...*spi.Event) error { + assert.Len(t, events, 1) + event := events[0] + + assert.Equal(t, spi.IssuerOIDCInteractionAckExpired, event.Type) + + var dat oidc4ci.EventPayload + b, _ := json.Marshal(event.Data) //nolint + assert.NoError(t, json.Unmarshal(b, &dat)) + + assert.Equal(t, "4567", dat.ProfileID) + assert.Equal(t, "2222", dat.ProfileVersion) + assert.Equal(t, "1111", dat.OrgID) + assert.Equal(t, "1234", dat.WebHook) + assert.Equal(t, "wallet", dat.ErrorComponent) + assert.Equal(t, "some-random-text", dat.Error) + + return nil + }) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + Status: "failure", + ErrorText: "some-random-text", + IssuerIdentifier: "https://someurl/some_issuer/v1.0", + }) + assert.ErrorContains(t, err, "ack expired") + }) + + t.Run("success with short identifier", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + eventSvc := NewMockEventService(gomock.NewController(t)) + profileSvc := NewMockProfileService(gomock.NewController(t)) + + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + EventSvc: eventSvc, + ProfileSvc: profileSvc, + }) + + profileSvc.EXPECT().GetProfile("some_issuer", "v1.0"). + Return(&profile.Issuer{ + WebHook: "1234", + ID: "4567", + Version: "2222", + OrganizationID: "1111", + }, nil) + + store.EXPECT().Get(gomock.Any(), "123").Return(nil, oidc4ci.ErrDataNotFound) + eventSvc.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, _ string, events ...*spi.Event) error { + assert.Len(t, events, 1) + event := events[0] + + assert.Equal(t, spi.IssuerOIDCInteractionAckExpired, event.Type) + + var dat oidc4ci.EventPayload + b, _ := json.Marshal(event.Data) //nolint + assert.NoError(t, json.Unmarshal(b, &dat)) + + assert.Equal(t, "4567", dat.ProfileID) + assert.Equal(t, "2222", dat.ProfileVersion) + assert.Equal(t, "1111", dat.OrgID) + assert.Equal(t, "1234", dat.WebHook) + assert.Equal(t, "wallet", dat.ErrorComponent) + assert.Equal(t, "some-random-text", dat.Error) + + return nil + }) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + Status: "failure", + ErrorText: "some-random-text", + IssuerIdentifier: "some_issuer/v1.0", + }) + assert.ErrorContains(t, err, "ack expired") + }) + + t.Run("no store", func(t *testing.T) { + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{}) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + Status: "failure", + ErrorText: "some-random-text", + }) + assert.NoError(t, err) + }) + + t.Run("missing identifier", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + eventSvc := NewMockEventService(gomock.NewController(t)) + profileSvc := NewMockProfileService(gomock.NewController(t)) + + store.EXPECT().Get(gomock.Any(), "123").Return(nil, oidc4ci.ErrDataNotFound) + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + EventSvc: eventSvc, + ProfileSvc: profileSvc, + }) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + Status: "failure", + ErrorText: "some-random-text", + }) + assert.ErrorContains(t, err, "issuer identifier is empty and ack not found") + }) + + t.Run("invalid identifier", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + eventSvc := NewMockEventService(gomock.NewController(t)) + profileSvc := NewMockProfileService(gomock.NewController(t)) + + store.EXPECT().Get(gomock.Any(), "123").Return(nil, oidc4ci.ErrDataNotFound) + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + EventSvc: eventSvc, + ProfileSvc: profileSvc, + }) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + Status: "failure", + ErrorText: "some-random-text", + IssuerIdentifier: "abcd", + }) + assert.ErrorContains(t, err, "invalid issuer identifier. expected format") + }) + + t.Run("profile not found", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + eventSvc := NewMockEventService(gomock.NewController(t)) + profileSvc := NewMockProfileService(gomock.NewController(t)) + + store.EXPECT().Get(gomock.Any(), "123").Return(nil, oidc4ci.ErrDataNotFound) + profileSvc.EXPECT().GetProfile("some_issuer", "v1.0").Return(nil, + errors.New("profile not found")) + + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + EventSvc: eventSvc, + ProfileSvc: profileSvc, + }) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + Status: "failure", + ErrorText: "some-random-text", + IssuerIdentifier: "some_issuer/v1.0", + }) + assert.ErrorContains(t, err, "profile not found") + }) + + t.Run("publish err", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + eventSvc := NewMockEventService(gomock.NewController(t)) + profileSvc := NewMockProfileService(gomock.NewController(t)) + + profileSvc.EXPECT().GetProfile("some_issuer", "v1.0"). + Return(&profile.Issuer{ + WebHook: "1234", + ID: "4567", + Version: "2222", + OrganizationID: "1111", + }, nil) + + store.EXPECT().Get(gomock.Any(), "123").Return(nil, oidc4ci.ErrDataNotFound) + eventSvc.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Any()). + Return(errors.New("publish err")) + + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + EventSvc: eventSvc, + ProfileSvc: profileSvc, + }) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + Status: "failure", + ErrorText: "some-random-text", + IssuerIdentifier: "some_issuer/v1.0", + }) + assert.ErrorContains(t, err, "publish err") + }) +} + +func TestAck(t *testing.T) { + t.Run("success", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + eventSvc := NewMockEventService(gomock.NewController(t)) + + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + EventSvc: eventSvc, + }) + + store.EXPECT().Get(gomock.Any(), "123").Return(&oidc4ci.Ack{ + HashedToken: "abcds", + ProfileID: "profile1", + ProfileVersion: "v2.0", + TxID: "333", + WebHookURL: "444", + OrgID: "555", + }, nil) + eventSvc.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, _ string, events ...*spi.Event) error { + assert.Len(t, events, 1) + event := events[0] + + assert.Equal(t, spi.IssuerOIDCInteractionAckFailed, event.Type) + + var dat oidc4ci.EventPayload + b, _ := json.Marshal(event.Data) //nolint + assert.NoError(t, json.Unmarshal(b, &dat)) + + assert.Equal(t, "profile1", dat.ProfileID) + assert.Equal(t, "v2.0", dat.ProfileVersion) + assert.Equal(t, "555", dat.OrgID) + assert.Equal(t, "444", dat.WebHook) + assert.Equal(t, "wallet", dat.ErrorComponent) + assert.Equal(t, "some-random-text", dat.Error) + + return nil + }) + + store.EXPECT().Delete(gomock.Any(), "123").Return(errors.New("ignored")) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + Status: "failure", + ErrorText: "some-random-text", + }) + assert.NoError(t, err) + }) + + t.Run("store err", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + eventSvc := NewMockEventService(gomock.NewController(t)) + + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + EventSvc: eventSvc, + }) + + store.EXPECT().Get(gomock.Any(), "123").Return(nil, + errors.New("store err")) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + }) + assert.ErrorContains(t, err, "store err") + }) + + t.Run("invalid token", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + eventSvc := NewMockEventService(gomock.NewController(t)) + + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + EventSvc: eventSvc, + }) + + store.EXPECT().Get(gomock.Any(), "123").Return(&oidc4ci.Ack{ + HashedToken: "123", + }, nil) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + }) + assert.ErrorContains(t, err, "invalid token") + }) + + t.Run("evn map", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + eventSvc := NewMockEventService(gomock.NewController(t)) + + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + EventSvc: eventSvc, + }) + + store.EXPECT().Get(gomock.Any(), "123").Return(&oidc4ci.Ack{ + HashedToken: "abcds", + }, nil) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + Status: "xxx", + }) + assert.ErrorContains(t, err, "invalid status: xxx") + }) + + t.Run("event send err", func(t *testing.T) { + store := NewMockAckStore(gomock.NewController(t)) + eventSvc := NewMockEventService(gomock.NewController(t)) + + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{ + AckStore: store, + EventSvc: eventSvc, + }) + + store.EXPECT().Get(gomock.Any(), "123").Return(&oidc4ci.Ack{ + HashedToken: "abcds", + }, nil) + + eventSvc.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Any()). + Return(errors.New("event send err")) + + err := srv.Ack(context.TODO(), oidc4ci.AckRemote{ + HashedToken: "abcds", + ID: "123", + Status: "rejected", + }) + assert.ErrorContains(t, err, "event send err") + }) + + t.Run("test mapping", func(t *testing.T) { + testCases := []struct { + Input string + Output spi.EventType + Error string + }{ + { + Input: "success", + Output: spi.IssuerOIDCInteractionAckSucceeded, + }, + { + Input: "failure", + Output: spi.IssuerOIDCInteractionAckFailed, + }, + { + Input: "rejected", + Output: spi.IssuerOIDCInteractionAckRejected, + }, + { + Input: "unk", + Output: spi.IssuerOIDCInteractionAckFailed, + Error: "invalid status: unk", + }, + } + + for _, tc := range testCases { + t.Run(tc.Input, func(t *testing.T) { + srv := oidc4ci.NewAckService(&oidc4ci.AckServiceConfig{}) + event, err := srv.AckEventMap(tc.Input) + assert.Equal(t, tc.Output, event) + + if tc.Error != "" { + assert.ErrorContains(t, err, tc.Error) + } else { + assert.NoError(t, err) + } + }) + } + }) +} diff --git a/pkg/service/oidc4ci/oidc4ci_service.go b/pkg/service/oidc4ci/oidc4ci_service.go index 5a70d9f46..457fc5d32 100644 --- a/pkg/service/oidc4ci/oidc4ci_service.go +++ b/pkg/service/oidc4ci/oidc4ci_service.go @@ -4,7 +4,7 @@ Copyright SecureKey Technologies Inc. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */ -//go:generate mockgen -destination oidc4ci_service_mocks_test.go -self_package mocks -package oidc4ci_test -source=oidc4ci_service.go -mock_names transactionStore=MockTransactionStore,wellKnownService=MockWellKnownService,eventService=MockEventService,pinGenerator=MockPinGenerator,credentialOfferReferenceStore=MockCredentialOfferReferenceStore,claimDataStore=MockClaimDataStore,profileService=MockProfileService,dataProtector=MockDataProtector,kmsRegistry=MockKMSRegistry,cryptoJWTSigner=MockCryptoJWTSigner,jsonSchemaValidator=MockJSONSchemaValidator,clientAttestationService=MockClientAttestationService +//go:generate mockgen -destination oidc4ci_service_mocks_test.go -self_package mocks -package oidc4ci_test -source=oidc4ci_service.go -mock_names transactionStore=MockTransactionStore,wellKnownService=MockWellKnownService,eventService=MockEventService,pinGenerator=MockPinGenerator,credentialOfferReferenceStore=MockCredentialOfferReferenceStore,claimDataStore=MockClaimDataStore,profileService=MockProfileService,dataProtector=MockDataProtector,kmsRegistry=MockKMSRegistry,cryptoJWTSigner=MockCryptoJWTSigner,jsonSchemaValidator=MockJSONSchemaValidator,clientAttestationService=MockClientAttestationService,ackStore=MockAckStore,ackService=MockAckService package oidc4ci @@ -122,6 +122,23 @@ type clientAttestationService interface { ValidateAttestationJWTVP(ctx context.Context, profile *profileapi.Issuer, jwtVP string) error } +type ackStore interface { + Create(ctx context.Context, data *Ack) (string, error) + Get(ctx context.Context, id string) (*Ack, error) + Delete(ctx context.Context, id string) error +} + +type ackService interface { + Ack( + ctx context.Context, + req AckRemote, + ) error + CreateAck( + ctx context.Context, + ack *Ack, + ) (*string, error) +} + // Config holds configuration options and dependencies for Service. type Config struct { TransactionStore transactionStore @@ -140,6 +157,7 @@ type Config struct { CryptoJWTSigner cryptoJWTSigner JSONSchemaValidator jsonSchemaValidator ClientAttestationService clientAttestationService + AckService ackService } // Service implements VCS credential interaction API for OIDC credential issuance. @@ -160,6 +178,7 @@ type Service struct { cryptoJWTSigner cryptoJWTSigner schemaValidator jsonSchemaValidator clientAttestationService clientAttestationService + ackService ackService } // NewService returns a new Service instance. @@ -181,6 +200,7 @@ func NewService(config *Config) (*Service, error) { cryptoJWTSigner: config.CryptoJWTSigner, schemaValidator: config.JSONSchemaValidator, clientAttestationService: config.ClientAttestationService, + ackService: config.AckService, }, nil } @@ -452,7 +472,7 @@ func (s *Service) ValidatePreAuthorizedCodeRequest( //nolint:gocognit,nolintlint return tx, nil } -func (s *Service) PrepareCredential( +func (s *Service) PrepareCredential( //nolint:funlen ctx context.Context, req *PrepareCredential, ) (*PrepareCredentialResult, error) { @@ -532,15 +552,27 @@ func (s *Service) PrepareCredential( return nil, e } - if errSendEvent := s.sendTransactionEvent(ctx, tx, spi.IssuerOIDCInteractionSucceeded); errSendEvent != nil { - return nil, errSendEvent - } - cred, err := verifiable.CreateCredential(vcc, customFields) if err != nil { return nil, fmt.Errorf("create cred: %w", err) } + ack, err := s.ackService.CreateAck(ctx, &Ack{ + HashedToken: req.HashedToken, + ProfileID: tx.ProfileID, + ProfileVersion: tx.ProfileVersion, + TxID: tx.ID, + WebHookURL: tx.WebHookURL, + OrgID: tx.OrgID, + }) + if err != nil { // its not critical and should not break the flow + logger.Errorc(ctx, errors.Join(err, errors.New("can not create ack")).Error()) + } + + if errSendEvent := s.sendTransactionEvent(ctx, tx, spi.IssuerOIDCInteractionSucceeded); errSendEvent != nil { + return nil, errSendEvent + } + return &PrepareCredentialResult{ ProfileID: tx.ProfileID, ProfileVersion: tx.ProfileVersion, @@ -550,6 +582,7 @@ func (s *Service) PrepareCredential( Retry: false, EnforceStrictValidation: tx.CredentialTemplate.Checks.Strict, CredentialTemplate: tx.CredentialTemplate, + AckID: ack, }, nil } diff --git a/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go b/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go index 311d5b876..a5c1016f4 100644 --- a/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go +++ b/pkg/service/oidc4ci/oidc4ci_service_initiate_issuance_test.go @@ -49,6 +49,7 @@ type mocks struct { pinGenerator *MockPinGenerator crypto *MockDataProtector jsonSchemaValidator *MockJSONSchemaValidator + ackService *MockAckService } func TestService_InitiateIssuance(t *testing.T) { diff --git a/pkg/service/oidc4ci/oidc4ci_service_test.go b/pkg/service/oidc4ci/oidc4ci_service_test.go index 610d97d8e..a850cd031 100644 --- a/pkg/service/oidc4ci/oidc4ci_service_test.go +++ b/pkg/service/oidc4ci/oidc4ci_service_test.go @@ -1276,6 +1276,10 @@ func TestService_PrepareCredential(t *testing.T) { }, }, nil) + m.ackService.EXPECT().CreateAck(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, ack *oidc4ci.Ack) (*string, error) { + return lo.ToPtr("ackID"), nil + }) claimData := `{"surname":"Smith","givenName":"Pat","jobTitle":"Worker"}` httpClient = &http.Client{ @@ -1312,6 +1316,7 @@ func TestService_PrepareCredential(t *testing.T) { check: func(t *testing.T, resp *oidc4ci.PrepareCredentialResult, err error) { require.NoError(t, err) require.NotNil(t, resp) + require.Equal(t, "ackID", *resp.AckID) }, }, { @@ -1330,6 +1335,10 @@ func TestService_PrepareCredential(t *testing.T) { }, nil) claimData := `{"surname":"Smith","givenName":"Pat","jobTitle":"Worker"}` + m.ackService.EXPECT().CreateAck(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, ack *oidc4ci.Ack) (*string, error) { + return lo.ToPtr("ackID"), nil + }) httpClient = &http.Client{ Transport: &mockTransport{ @@ -1388,6 +1397,10 @@ func TestService_PrepareCredential(t *testing.T) { }, nil) claimData := `{"surname":"Smith","givenName":"Pat","jobTitle":"Worker"}` + m.ackService.EXPECT().CreateAck(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, ack *oidc4ci.Ack) (*string, error) { + return lo.ToPtr("ackID"), nil + }) httpClient = &http.Client{ Transport: &mockTransport{ @@ -1446,9 +1459,19 @@ func TestService_PrepareCredential(t *testing.T) { IsPreAuthFlow: true, ClaimDataID: claimID, CredentialFormat: vcsverifiable.Jwt, + OrgID: "asdasd", + WebHookURL: "aaaaa", }, }, nil) + m.ackService.EXPECT().CreateAck(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, ack *oidc4ci.Ack) (*string, error) { + require.Equal(t, "asdasd", ack.OrgID) + require.Equal(t, "aaaaa", ack.WebHookURL) + + return lo.ToPtr("ackID"), nil + }) + m.eventService.EXPECT().Publish(gomock.Any(), spi.IssuerEventTopic, gomock.Any()). DoAndReturn(func(ctx context.Context, topic string, messages ...*spi.Event) error { assert.Len(t, messages, 1) @@ -1488,6 +1511,68 @@ func TestService_PrepareCredential(t *testing.T) { require.NotNil(t, resp) }, }, + { + name: "Can not create ack", + setup: func(m *mocks) { + claimID := uuid.NewString() + m.transactionStore.EXPECT().Get(gomock.Any(), oidc4ci.TxID("txID")).Return(&oidc4ci.Transaction{ + ID: "txID", + TransactionData: oidc4ci.TransactionData{ + IssuerToken: "issuer-access-token", + CredentialTemplate: &profileapi.CredentialTemplate{ + Type: "VerifiedEmployee", + }, + IsPreAuthFlow: true, + ClaimDataID: claimID, + CredentialFormat: vcsverifiable.Jwt, + OrgID: "asdasd", + WebHookURL: "aaaaa", + }, + }, nil) + + m.ackService.EXPECT().CreateAck(gomock.Any(), gomock.Any()). + Return(nil, errors.New("can not create ack")) + + m.eventService.EXPECT().Publish(gomock.Any(), spi.IssuerEventTopic, gomock.Any()). + DoAndReturn(func(ctx context.Context, topic string, messages ...*spi.Event) error { + assert.Len(t, messages, 1) + assert.Equal(t, messages[0].Type, spi.IssuerOIDCInteractionSucceeded) + + return nil + }) + + m.transactionStore.EXPECT().Update(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, tx *oidc4ci.Transaction) error { + assert.Equal(t, oidc4ci.TransactionStateCredentialsIssued, tx.State) + return nil + }) + + clData := &oidc4ci.ClaimData{ + EncryptedData: &dataprotect.EncryptedData{ + Encrypted: []byte{0x1, 0x2, 0x3}, + EncryptedNonce: []byte{0x0, 0x2}, + }, + } + + m.claimDataStore.EXPECT().GetAndDelete(gomock.Any(), claimID).Return(clData, nil) + + m.crypto.EXPECT().Decrypt(gomock.Any(), clData.EncryptedData). + DoAndReturn(func(ctx context.Context, chunks *dataprotect.EncryptedData) ([]byte, error) { + b, _ := json.Marshal(map[string]interface{}{}) + return b, nil + }) + + req = &oidc4ci.PrepareCredential{ + TxID: "txID", + AudienceClaim: "/oidc/idp//", + } + }, + check: func(t *testing.T, resp *oidc4ci.PrepareCredentialResult, err error) { + require.NoError(t, err) + require.NotNil(t, resp) + require.Nil(t, resp.AckID) + }, + }, { name: "Failed to get claims for pre-authorized flow", setup: func(m *mocks) { @@ -1542,6 +1627,9 @@ func TestService_PrepareCredential(t *testing.T) { }, }, nil) + m.ackService.EXPECT().CreateAck(gomock.Any(), gomock.Any()). + Return(lo.ToPtr("123"), nil) + m.transactionStore.EXPECT().Update(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, tx *oidc4ci.Transaction) error { assert.Equal(t, oidc4ci.TransactionStateCredentialsIssued, tx.State) @@ -1874,6 +1962,7 @@ func TestService_PrepareCredential(t *testing.T) { claimDataStore: NewMockClaimDataStore(gomock.NewController(t)), eventService: NewMockEventService(gomock.NewController(t)), crypto: NewMockDataProtector(gomock.NewController(t)), + ackService: NewMockAckService(gomock.NewController(t)), } tt.setup(m) @@ -1885,6 +1974,7 @@ func TestService_PrepareCredential(t *testing.T) { EventService: m.eventService, EventTopic: spi.IssuerEventTopic, DataProtector: m.crypto, + AckService: m.ackService, }) require.NoError(t, err) diff --git a/pkg/service/wellknown/provider/wellknown_service.go b/pkg/service/wellknown/provider/wellknown_service.go index 1a100aaa9..f802cad8f 100644 --- a/pkg/service/wellknown/provider/wellknown_service.go +++ b/pkg/service/wellknown/provider/wellknown_service.go @@ -18,6 +18,7 @@ import ( "github.com/samber/lo" "github.com/trustbloc/vc-go/jwt" + "github.com/trustbloc/vcs/pkg/doc/vc" "github.com/trustbloc/vcs/pkg/kms" profileapi "github.com/trustbloc/vcs/pkg/profile" @@ -143,7 +144,8 @@ func (s *Service) getOpenIDIssuerConfig(issuerProfile *profileapi.Issuer) *issue ResponseTypesSupported: []string{ "code", }, - TokenEndpoint: fmt.Sprintf("%soidc/token", host), + TokenEndpoint: fmt.Sprintf("%soidc/token", host), + CredentialAckEndpoint: fmt.Sprintf("%soidc/acknowledgement", host), } if issuerProfile.OIDCConfig != nil { diff --git a/pkg/storage/redis/ackstore/ackstore.go b/pkg/storage/redis/ackstore/ackstore.go new file mode 100644 index 000000000..328172728 --- /dev/null +++ b/pkg/storage/redis/ackstore/ackstore.go @@ -0,0 +1,86 @@ +/* +Copyright Avast Software. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package ackstore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/trustbloc/vcs/pkg/service/oidc4ci" +) + +const ( + keyPrefix = "oidc4ci_ack" +) + +// Store stores claim data with expiration. +type Store struct { + redisClient redisClient + ttl time.Duration +} + +// New creates presentation claims store. +func New(redisClient redisClient, ttlSec int32) *Store { + return &Store{ + redisClient: redisClient, + ttl: time.Duration(ttlSec) * time.Second, + } +} + +func (s *Store) Create( + ctx context.Context, + ack *oidc4ci.Ack, +) (string, error) { + id := string(ack.TxID) // for now, it should much with TxID before new spec. + + b, err := json.Marshal(ack) + if err != nil { + return "", err + } + + if err = s.redisClient.API().Set(ctx, s.resolveRedisKey(id), string(b), s.ttl).Err(); err != nil { + return "", fmt.Errorf("redis insert received claims data: %w", err) + } + + return id, nil +} + +func (s *Store) Get(ctx context.Context, id string) (*oidc4ci.Ack, error) { + b, err := s.redisClient.API().Get(ctx, s.resolveRedisKey(id)).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, oidc4ci.ErrDataNotFound + } + + return nil, err + } + + var doc oidc4ci.Ack + if err = json.Unmarshal(b, &doc); err != nil { + return nil, fmt.Errorf("data decode: %w", err) + } + + return &doc, nil +} + +func (s *Store) Delete(ctx context.Context, id string) error { + err := s.redisClient.API().Del(ctx, s.resolveRedisKey(id)).Err() + if err != nil { + return fmt.Errorf("failed to delete ack with id[%s]: %w", id, err) + } + + return nil +} + +func (s *Store) resolveRedisKey(id string) string { + return fmt.Sprintf("%s-%s", keyPrefix, id) +} diff --git a/pkg/storage/redis/ackstore/ackstore_test.go b/pkg/storage/redis/ackstore/ackstore_test.go new file mode 100644 index 000000000..c400735d3 --- /dev/null +++ b/pkg/storage/redis/ackstore/ackstore_test.go @@ -0,0 +1,172 @@ +package ackstore_test + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/golang/mock/gomock" + redisapi "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + + "github.com/trustbloc/vcs/pkg/service/oidc4ci" + "github.com/trustbloc/vcs/pkg/storage/redis/ackstore" +) + +func TestCreate(t *testing.T) { + t.Run("success", func(t *testing.T) { + cl := NewMockredisClient(gomock.NewController(t)) + api := NewMockredisApi(gomock.NewController(t)) + + cl.EXPECT().API().Return(api).AnyTimes() + + store := ackstore.New(cl, 30) + + obj := &oidc4ci.Ack{ + TxID: "12354", + HashedToken: "abcd", + } + + b, _ := json.Marshal(obj) //nolint + + api.EXPECT().Set(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, s string, i interface{}, duration time.Duration) *redisapi.StatusCmd { + assert.True(t, strings.HasPrefix(s, "oidc4ci_ack")) + assert.Equal(t, 30*time.Second, duration) + assert.Equal(t, string(b), i) + + return &redisapi.StatusCmd{} + }) + id, err := store.Create(context.TODO(), obj) + assert.NoError(t, err) + assert.NotEmpty(t, id) + assert.EqualValues(t, id, obj.TxID) + }) + + t.Run("err", func(t *testing.T) { + cl := NewMockredisClient(gomock.NewController(t)) + api := NewMockredisApi(gomock.NewController(t)) + + cl.EXPECT().API().Return(api).AnyTimes() + + store := ackstore.New(cl, 30) + + obj := &oidc4ci.Ack{ + HashedToken: "abcd", + } + + api.EXPECT().Set(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(redisapi.NewStatusResult("", errors.New("unexpected err"))) + + id, err := store.Create(context.TODO(), obj) + assert.Empty(t, id) + assert.ErrorContains(t, err, "unexpected err") + }) +} + +func TestGet(t *testing.T) { + t.Run("success", func(t *testing.T) { + cl := NewMockredisClient(gomock.NewController(t)) + api := NewMockredisApi(gomock.NewController(t)) + + cl.EXPECT().API().Return(api).AnyTimes() + + store := ackstore.New(cl, 30) + + obj := &oidc4ci.Ack{ + HashedToken: "abcd", + } + b, err := json.Marshal(obj) + assert.NoError(t, err) + + api.EXPECT().Get(gomock.Any(), "oidc4ci_ack-1234"). + Return(redisapi.NewStringResult(string(b), nil)) + + id, err := store.Get(context.TODO(), "1234") + assert.NoError(t, err) + assert.Equal(t, id.HashedToken, obj.HashedToken) + }) + + t.Run("unmarshal err", func(t *testing.T) { + cl := NewMockredisClient(gomock.NewController(t)) + api := NewMockredisApi(gomock.NewController(t)) + + cl.EXPECT().API().Return(api).AnyTimes() + + store := ackstore.New(cl, 30) + + api.EXPECT().Get(gomock.Any(), "oidc4ci_ack-1234"). + Return(redisapi.NewStringResult("[]", nil)) + + id, err := store.Get(context.TODO(), "1234") + assert.Nil(t, id) + assert.ErrorContains(t, err, "cannot unmarshal array into Go value of type") + }) + + t.Run("err nil", func(t *testing.T) { + cl := NewMockredisClient(gomock.NewController(t)) + api := NewMockredisApi(gomock.NewController(t)) + + cl.EXPECT().API().Return(api).AnyTimes() + + store := ackstore.New(cl, 30) + + api.EXPECT().Get(gomock.Any(), "oidc4ci_ack-1234"). + Return(redisapi.NewStringResult("", redisapi.Nil)) + + id, err := store.Get(context.TODO(), "1234") + assert.Nil(t, id) + assert.ErrorIs(t, err, oidc4ci.ErrDataNotFound) + }) + + t.Run("err", func(t *testing.T) { + cl := NewMockredisClient(gomock.NewController(t)) + api := NewMockredisApi(gomock.NewController(t)) + + cl.EXPECT().API().Return(api).AnyTimes() + + store := ackstore.New(cl, 30) + + api.EXPECT().Get(gomock.Any(), "oidc4ci_ack-1234"). + Return(redisapi.NewStringResult("", errors.New("unexpected err"))) + + id, err := store.Get(context.TODO(), "1234") + assert.Nil(t, id) + assert.ErrorContains(t, err, "unexpected err") + }) +} + +func TestDelete(t *testing.T) { + t.Run("success", func(t *testing.T) { + cl := NewMockredisClient(gomock.NewController(t)) + api := NewMockredisApi(gomock.NewController(t)) + + cl.EXPECT().API().Return(api).AnyTimes() + + store := ackstore.New(cl, 30) + + api.EXPECT().Del(gomock.Any(), "oidc4ci_ack-1234"). + Return(redisapi.NewIntResult(1, nil)) + + err := store.Delete(context.TODO(), "1234") + assert.NoError(t, err) + }) + + t.Run("err", func(t *testing.T) { + cl := NewMockredisClient(gomock.NewController(t)) + api := NewMockredisApi(gomock.NewController(t)) + + cl.EXPECT().API().Return(api).AnyTimes() + + store := ackstore.New(cl, 30) + + api.EXPECT().Del(gomock.Any(), "oidc4ci_ack-1234"). + Return(redisapi.NewIntResult(0, errors.New("some error"))) + + err := store.Delete(context.TODO(), "1234") + assert.ErrorContains(t, err, "some error") + }) +} diff --git a/pkg/storage/redis/ackstore/interfaces.go b/pkg/storage/redis/ackstore/interfaces.go new file mode 100644 index 000000000..4a5ec5574 --- /dev/null +++ b/pkg/storage/redis/ackstore/interfaces.go @@ -0,0 +1,14 @@ +package ackstore + +import redisapi "github.com/redis/go-redis/v9" + +//go:generate mockgen -destination interfaces_mocks_test.go -package ackstore_test -source=interfaces.go + +type redisClient interface { + API() redisapi.UniversalClient +} + +// nolint +type redisApi interface { + redisapi.UniversalClient +} diff --git a/scripts/check_license.sh b/scripts/check_license.sh index 88219d7fc..d4f3d4113 100755 --- a/scripts/check_license.sh +++ b/scripts/check_license.sh @@ -12,7 +12,7 @@ function filterExcludedFiles { | grep -v .pem$ | grep -v .block$ | grep -v .tx$ | grep -v ^LICENSE$ | grep -v _sk$ \ | grep -v .key$ | grep -v .crt$ | grep -v \\.gen.go$ | grep -v \\.json$ | grep -v \\.jsonld | grep -v Gopkg.lock$ \ | grep -v .md$ | grep -v ^vendor/ | grep -v ^build/ | grep -v .pb.go$ | grep -v ci.properties$ \ - | grep -v go.sum$ | grep -v openapi/ | grep -v component/echo/ | grep -v test/bdd/fixtures/file-server \ + | grep -v go.sum$ | grep -v _mocks.go$ | grep -v openapi/ | grep -v component/echo/ | grep -v test/bdd/fixtures/file-server \ | grep -v \\.jwt | grep -v \\.sdjwt | grep -v \\.tmpl | sort -u` } diff --git a/test/bdd/cognito-auth/main.go b/test/bdd/cognito-auth/main.go index 2a3232f58..04121d839 100644 --- a/test/bdd/cognito-auth/main.go +++ b/test/bdd/cognito-auth/main.go @@ -7,6 +7,10 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/http" + "net/url" + "os" + "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" cip "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" @@ -16,9 +20,6 @@ import ( "github.com/samber/lo" "github.com/trustbloc/logutil-go/pkg/log" "go.uber.org/zap" - "net/http" - "net/url" - "os" ) var logger = log.New("cognito-auth") @@ -145,7 +146,7 @@ func writeJsonResponse(w http.ResponseWriter, code int, resp interface{}) { } func computeSecretHash(username, clientID, clientSecret string) string { - // Base64 ( HMAC_SHA256 ( "Client Secret Key", "Username" + "Client Id" ) ) + // Base64 ( HMAC_SHA256 ( "Client Secret Key", "Username" + "Client ID" ) ) mac := hmac.New(sha256.New, []byte(clientSecret)) mac.Write([]byte(username + clientID)) diff --git a/test/bdd/fixtures/krakend-config/settings/endpoint.json b/test/bdd/fixtures/krakend-config/settings/endpoint.json index fb95fd11c..bda509af2 100644 --- a/test/bdd/fixtures/krakend-config/settings/endpoint.json +++ b/test/bdd/fixtures/krakend-config/settings/endpoint.json @@ -131,6 +131,14 @@ "Content-Type" ] }, + { + "endpoint": "/oidc/acknowledgement", + "method": "POST", + "input_headers": [ + "Authorization", + "Content-Type" + ] + }, { "endpoint": "/verifier/profiles/{profileID}/{profileVersion}/credentials/verify", "method": "POST",