diff --git a/config/config.go b/config/config.go index c94e2f778..05472e0bd 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,7 @@ import ( "github.com/joho/godotenv" "github.com/pkg/errors" "github.com/sirupsen/logrus" + "golang.org/x/oauth2/clientcredentials" ) const ( @@ -35,6 +36,11 @@ type SSIServiceConfig struct { Services ServicesConfig `toml:"services"` } +type OIDCConfig struct { + IntrospectEndpoint string `toml:"introspect_endpoint" conf:"http://authserver/introspect"` + ClientCredentials clientcredentials.Config `toml:"client_credentials"` +} + // ServerConfig represents configurable properties for the HTTP server type ServerConfig struct { APIHost string `toml:"api_host" conf:"default:0.0.0.0:3000"` @@ -48,6 +54,7 @@ type ServerConfig struct { LogLevel string `toml:"log_level" conf:"default:debug"` EnableSchemaCaching bool `toml:"enable_schema_caching" conf:"default:true"` EnableAllowAllCORS bool `toml:"enable_allow_all_cors" conf:"default:false"` + OIDCConfig OIDCConfig `toml:"oidc_config"` } type IssuingServiceConfig struct { @@ -61,6 +68,10 @@ func (s *IssuingServiceConfig) IsEmpty() bool { return reflect.DeepEqual(s, &IssuingServiceConfig{}) } +type OIDCCredentialServiceConfig struct { + CNonceExpiresIn *time.Duration `toml:"c_nonce_expired_in,omitempty"` +} + // ServicesConfig represents configurable properties for the components of the SSI Service type ServicesConfig struct { // at present, it is assumed that a single storage provider works for all services @@ -71,14 +82,15 @@ type ServicesConfig struct { ServiceEndpoint string `toml:"service_endpoint"` // Embed all service-specific configs here. The order matters: from which should be instantiated first, to last - KeyStoreConfig KeyStoreServiceConfig `toml:"keystore,omitempty"` - DIDConfig DIDServiceConfig `toml:"did,omitempty"` - IssuingServiceConfig IssuingServiceConfig `toml:"issuing,omitempty"` - SchemaConfig SchemaServiceConfig `toml:"schema,omitempty"` - CredentialConfig CredentialServiceConfig `toml:"credential,omitempty"` - ManifestConfig ManifestServiceConfig `toml:"manifest,omitempty"` - PresentationConfig PresentationServiceConfig `toml:"presentation,omitempty"` - WebhookConfig WebhookServiceConfig `toml:"webhook,omitempty"` + KeyStoreConfig KeyStoreServiceConfig `toml:"keystore,omitempty"` + DIDConfig DIDServiceConfig `toml:"did,omitempty"` + IssuingServiceConfig IssuingServiceConfig `toml:"issuing,omitempty"` + SchemaConfig SchemaServiceConfig `toml:"schema,omitempty"` + CredentialConfig CredentialServiceConfig `toml:"credential,omitempty"` + ManifestConfig ManifestServiceConfig `toml:"manifest,omitempty"` + PresentationConfig PresentationServiceConfig `toml:"presentation,omitempty"` + WebhookConfig WebhookServiceConfig `toml:"webhook,omitempty"` + OIDCCredentialConfig OIDCCredentialServiceConfig `toml:"oidc,omitempty"` } // BaseServiceConfig represents configurable properties for a specific component of the SSI Service @@ -277,6 +289,7 @@ func loadDefaultServicesConfig(config *SSIServiceConfig) { WebhookConfig: WebhookServiceConfig{ BaseServiceConfig: &BaseServiceConfig{Name: "webhook"}, }, + OIDCCredentialConfig: OIDCCredentialServiceConfig{}, } config.Services = servicesConfig diff --git a/go.mod b/go.mod index 8a229948d..0845bdbc9 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/google/uuid v1.3.0 github.com/joho/godotenv v1.5.1 github.com/lestrrat-go/jwx v1.2.25 + github.com/lestrrat-go/jwx/v2 v2.0.9 github.com/magefile/mage v1.14.0 github.com/mr-tron/base58 v1.2.0 github.com/multiformats/go-multibase v0.2.0 @@ -25,6 +26,7 @@ require ( github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 github.com/ory/fosite v0.44.0 github.com/pkg/errors v0.9.1 + github.com/pquerna/otp v1.4.0 github.com/redis/go-redis/extra/redisotel/v9 v9.0.2 github.com/redis/go-redis/v9 v9.0.2 github.com/rs/cors v1.8.3 @@ -38,6 +40,7 @@ require ( go.opentelemetry.io/otel/sdk v1.14.0 go.opentelemetry.io/otel/trace v1.14.0 golang.org/x/crypto v0.7.0 + golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d gopkg.in/go-playground/validator.v9 v9.31.0 ) @@ -48,6 +51,7 @@ require ( github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 // indirect github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect github.com/bits-and-blooms/bitset v1.5.0 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cristalhq/jwt/v4 v4.0.2 // indirect github.com/dave/jennifer v1.4.0 // indirect @@ -79,7 +83,6 @@ require ( github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.4 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/jwx/v2 v2.0.9 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/magiconair/properties v1.8.1 // indirect github.com/mattn/goveralls v0.0.6 // indirect @@ -110,7 +113,6 @@ require ( go.opentelemetry.io/otel/metric v0.37.0 // indirect golang.org/x/mod v0.8.0 // indirect golang.org/x/net v0.8.0 // indirect - golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d // indirect golang.org/x/sys v0.6.0 // indirect golang.org/x/term v0.6.0 // indirect golang.org/x/text v0.8.0 // indirect diff --git a/go.sum b/go.sum index 5121a909c..bb0b6126a 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ github.com/bits-and-blooms/bitset v1.5.0 h1:NpE8frKRLGHIcEzkR+gZhiioW1+WbYV6fKwD github.com/bits-and-blooms/bitset v1.5.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= github.com/bmatcuk/doublestar/v2 v2.0.3/go.mod h1:QMmcs3H2AUQICWhfzLXz+IYln8lRQmTZRptLie8RgRw= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.5.0 h1:aOAnND1T40wEdAtkGSkvSICWeQ8L3UASX7YVCqQx+eQ= github.com/bsm/ginkgo/v2 v2.5.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= github.com/bsm/gomega v1.20.0 h1:JhAwLmtRzXFTx2AkALSLa8ijZafntmhSoU63Ok18Uq8= @@ -871,6 +873,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc= github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= diff --git a/pkg/server/middleware/introspect.go b/pkg/server/middleware/introspect.go new file mode 100644 index 000000000..4ea674777 --- /dev/null +++ b/pkg/server/middleware/introspect.go @@ -0,0 +1,122 @@ +package middleware + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/goccy/go-json" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "github.com/tbd54566975/ssi-service/pkg/server/framework" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +type introspecter struct { + // Introspection endpoint according to https://www.rfc-editor.org/rfc/rfc7662. + endpoint string + + // Config of the client credentials to use for authenticating with Endpoint. + conf clientcredentials.Config +} + +func newIntrospect(endpoint string, config clientcredentials.Config) *introspecter { + return &introspecter{ + endpoint: endpoint, + conf: config, + } +} + +// Introspect extracts a token from the `Authorization` header, and determines whether it's active by using the +// Endpoint configured. A `nil` error represents an active token. +func (s introspecter) introspect(ctx context.Context, req *http.Request) error { + ctx = context.WithValue(ctx, oauth2.HTTPClient, http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}) + client := s.conf.Client(ctx) + // Send a request to the introspect endpoint to decide whether this is allowed. + authHeader := req.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + return errors.New("no bearer") + } + token := authHeader[len("Bearer "):] + + body := make(url.Values) + body.Set("token", token) + introspectionReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint, strings.NewReader(body.Encode())) + if err != nil { + return err + } + introspectionReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + introspectionResp, err := client.Do(introspectionReq) + if err != nil { + return err + } + defer func(body io.ReadCloser) { + err := body.Close() + if err != nil { + logrus.WithError(err).Warn("closing body") + } + }(introspectionResp.Body) + + if introspectionResp.StatusCode != http.StatusOK { + return fmt.Errorf("status does not indicate success: code: %d, body: %v", introspectionResp.StatusCode, introspectionResp.Body) + } + + result, err := extractIntrospectResult(introspectionResp.Body) + if err != nil { + return err + } + if !result.Active { + return errors.New("invalid token") + } + return nil +} + +func extractIntrospectResult(r io.Reader) (*result, error) { + res := result{ + Optionals: make(map[string]json.RawMessage), + } + + if err := json.NewDecoder(r).Decode(&res.Optionals); err != nil { + return nil, err + } + + if val, ok := res.Optionals["active"]; ok { + if err := json.Unmarshal(val, &res.Active); err != nil { + return nil, err + } + + delete(res.Optionals, "active") + } + + return &res, nil +} + +// result is the OAuth2 Introspection Result +type result struct { + Active bool + + Optionals map[string]json.RawMessage +} + +// Introspect creates a middleware which can be used to gate access to protected resources. +// This middleware works by extracting the token from the `Authorization` header and then sending a request to the +// introspect endpoint (which should be compliant with https://www.rfc-editor.org/rfc/rfc7662) to obtain the +// whether the token is active. A `nil` error represents an active token. +// config represents the client credentials to use for authenticating with the introspect endpoint. +func Introspect(endpoint string, config clientcredentials.Config) framework.Middleware { + intro := newIntrospect(endpoint, config) + return func(handler framework.Handler) framework.Handler { + return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + if err := intro.introspect(ctx, r); err != nil { + frameworkErr := framework.NewRequestErrorMsg("invalid_token", http.StatusUnauthorized) + return framework.RespondError(ctx, w, frameworkErr) + } + return handler(ctx, w, r) + } + } +} diff --git a/pkg/server/middleware/introspect_test.go b/pkg/server/middleware/introspect_test.go new file mode 100644 index 000000000..c1a31c7e4 --- /dev/null +++ b/pkg/server/middleware/introspect_test.go @@ -0,0 +1,93 @@ +package middleware + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tbd54566975/ssi-service/pkg/testutil" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +func TestIntrospect(t *testing.T) { + mockTokenServer := simpleOauthTokenServer() + defer mockTokenServer.Close() + conf := newConfig(mockTokenServer) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"active":true}`)) + })) + defer mockServer.Close() + + introspectMiddleware := Introspect(mockServer.URL, conf) + + handlerCalled := false + handler := introspectMiddleware(func(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + handlerCalled = true + return nil + }) + req := httptest.NewRequest(http.MethodPost, "/some_protected_url", strings.NewReader("")) + req.Header.Set("Authorization", "Bearer my-awesome-token") + require.NoError(t, handler(context.Background(), httptest.NewRecorder(), req)) + require.True(t, handlerCalled) +} + +func TestIntrospectReturnsError(t *testing.T) { + mockTokenServer := simpleOauthTokenServer() + defer mockTokenServer.Close() + conf := newConfig(mockTokenServer) + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"active":false}`)) + })) + defer mockServer.Close() + + introspectMiddleware := Introspect(mockServer.URL, conf) + + handler := introspectMiddleware(noOpHandler) + req := httptest.NewRequest(http.MethodPost, "/some_protected_url", strings.NewReader("")) + req.Header.Set("Authorization", "Bearer my-awesome-token") + w := httptest.NewRecorder() + err := handler(testutil.NewRequestContext(), w, req) + require.NoError(t, err) + assertCredentialErrorResponseEquals(t, w, `{"error":"invalid_token"}`) +} + +func assertCredentialErrorResponseEquals(t *testing.T, w *httptest.ResponseRecorder, s string) { + respBody, err := io.ReadAll(w.Body) + require.NoError(t, err) + require.JSONEq(t, s, string(respBody)) +} + +func noOpHandler(context.Context, http.ResponseWriter, *http.Request) error { + return nil +} + +func simpleOauthTokenServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-www-form-urlencoded") + values := url.Values{} + values.Set("access_token", "my-client-token") + _, _ = w.Write([]byte(values.Encode())) + })) +} + +func newConfig(mockTokenServer *httptest.Server) clientcredentials.Config { + conf := clientcredentials.Config{ + ClientID: "my-test-client", + ClientSecret: "", + TokenURL: mockTokenServer.URL, + Scopes: []string{"notsurewhatscope"}, + EndpointParams: nil, + AuthStyle: oauth2.AuthStyleInHeader, + } + return conf +} diff --git a/pkg/server/router/oidc_credential.go b/pkg/server/router/oidc_credential.go new file mode 100644 index 000000000..31f674c4a --- /dev/null +++ b/pkg/server/router/oidc_credential.go @@ -0,0 +1,80 @@ +package router + +import ( + "context" + "net/http" + + "github.com/pkg/errors" + "github.com/tbd54566975/ssi-service/pkg/server/framework" + svcframework "github.com/tbd54566975/ssi-service/pkg/service/framework" + "github.com/tbd54566975/ssi-service/pkg/service/oidc" + "github.com/tbd54566975/ssi-service/pkg/service/oidc/model" +) + +type OIDCCredentialRouter struct { + service *oidc.Service +} + +// IssueCredential implements https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-endpoint +func (r OIDCCredentialRouter) IssueCredential(ctx context.Context, w http.ResponseWriter, req *http.Request) error { + var credRequest model.CredentialRequest + if err := framework.Decode(req, &credRequest); err != nil { + return framework.NewRequestError(errors.Wrap(err, "decoding request"), http.StatusBadRequest) + } + + resp, err := r.service.CredentialEndpoint(ctx, &credRequest) + if err != nil { + return r.responseFromError(ctx, w, err) + } + + return framework.Respond(ctx, w, resp, http.StatusOK) +} + +// CredentialError implements https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-response +type CredentialError struct { + // Error is a string that describes the type of error that occurred. + Error string `json:"error"` + + // ErrorDescription is a string that provides additional information about the error that occurred. + ErrorDescription string `json:"error_description"` + + // CNonce is an optional JSON string nonce used to create a proof of possession of key material when requesting a Credential (see Section 7.2). + // When received, the Wallet must use this nonce value for subsequent credential requests until the Credential Issuer provides a fresh nonce. + CNonce string `json:"c_nonce,omitempty"` + + // CNonceExpiresIn is an optional JSON integer denoting the lifetime in seconds of the c_nonce. + CNonceExpiresIn int `json:"c_nonce_expires_in,omitempty"` + + // AcceptanceToken is an optional JSON string containing a security token used to subsequently obtain a Credential. + // It is required to be present when the Credential is not returned. + // Note that this is currently not implemented. + AcceptanceToken string `json:"acceptance_token,omitempty"` +} + +const InvalidOrMissingProof = "invalid_or_missing_proof" + +func (r OIDCCredentialRouter) responseFromError(ctx context.Context, w http.ResponseWriter, err error) error { + switch { + case errors.Is(err, oidc.ErrNonceNotString) || errors.Is(err, oidc.ErrNonceDifferent) || errors.Is(err, oidc.ErrNonceNotPresent): + nonce, nonceErr := r.service.CurrentNonce() + if nonceErr != nil { + return framework.RespondError(ctx, w, nonceErr) + } + return framework.Respond(ctx, w, CredentialError{ + Error: InvalidOrMissingProof, + ErrorDescription: err.Error(), + CNonce: nonce, + CNonceExpiresIn: r.service.NonceExpiresIn(), + }, http.StatusBadRequest) + default: + return framework.RespondError(ctx, w, err) + } +} + +func NewOIDCCredentialRouter(s svcframework.Service) (*OIDCCredentialRouter, error) { + svc, ok := s.(*oidc.Service) + if !ok { + return nil, errors.New("cannot provide oidc service") + } + return &OIDCCredentialRouter{service: svc}, nil +} diff --git a/pkg/server/server.go b/pkg/server/server.go index c0c095b5f..f82680283 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -40,6 +40,7 @@ const ( KeyStorePrefix = "/keys" VerificationPath = "/verification" WebhookPrefix = "/webhooks" + OIDCPrefix = "/oidc" ) // SSIServer exposes all dependencies needed to run a http server and all its services @@ -120,6 +121,8 @@ func (s *SSIServer) instantiateRouter(service svcframework.Service, webhookServi return s.IssuanceAPI(service) case svcframework.Webhook: return s.WebhookAPI(service) + case svcframework.OIDC: + return s.OIDCAPI(service) default: return fmt.Errorf("could not instantiate API for service: %s", serviceType) } @@ -295,3 +298,17 @@ func (s *SSIServer) WebhookAPI(service svcframework.Service) (err error) { s.Handle(http.MethodGet, path.Join(handlerPath, "verbs"), webhookRouter.GetSupportedVerbs) return } + +func (s *SSIServer) OIDCAPI(service svcframework.Service) error { + r, err := router.NewOIDCCredentialRouter(service) + if err != nil { + return err + } + + handlerPath := V1Prefix + OIDCPrefix + + // TODO(https://github.com/TBD54566975/ssi-service/issues/329): Communication with the Credential Endpoint MUST utilize TLS. + s.Handle(http.MethodPost, path.Join(handlerPath, "/credentials"), r.IssueCredential, middleware.Introspect(s.OIDCConfig.IntrospectEndpoint, s.OIDCConfig.ClientCredentials)) + + return nil +} diff --git a/pkg/server/server_oidc_issuance_test.go b/pkg/server/server_oidc_issuance_test.go new file mode 100644 index 000000000..1c4fa1425 --- /dev/null +++ b/pkg/server/server_oidc_issuance_test.go @@ -0,0 +1,353 @@ +package server + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/TBD54566975/ssi-sdk/crypto" + didsdk "github.com/TBD54566975/ssi-sdk/did" + "github.com/TBD54566975/ssi-sdk/oidc/issuance" + "github.com/goccy/go-json" + "github.com/lestrrat-go/jwx/jwk" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/stretchr/testify/require" + "github.com/tbd54566975/ssi-service/pkg/server/router" + "github.com/tbd54566975/ssi-service/pkg/service/credential" + "github.com/tbd54566975/ssi-service/pkg/service/did" + "github.com/tbd54566975/ssi-service/pkg/service/oidc" + "github.com/tbd54566975/ssi-service/pkg/service/oidc/model" +) + +func TestAuthService_CredentialEndpoint(t *testing.T) { + bolt := setupTestDB(t) + keyStoreService := testKeyStoreService(t, bolt) + didService := testDIDService(t, bolt, keyStoreService) + schemaService := testSchemaService(t, bolt, keyStoreService, didService) + credService := testCredentialService(t, bolt, keyStoreService, didService, schemaService) + oidcRouter, err := router.NewOIDCCredentialRouter(oidc.NewOIDCService( + didService.GetResolver(), + credService, + [32]byte{ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, + 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + oidc.WithCNonceExpiresIn(1*time.Second), + )) + require.NoError(t, err) + + issuerDID, err := didService.CreateDIDByMethod(context.Background(), did.CreateDIDRequest{ + Method: didsdk.KeyMethod, + KeyType: crypto.Ed25519, + }) + require.NoError(t, err) + require.NotEmpty(t, issuerDID) + + // Testing is easier when it's deterministic, so we use the following DID, which is associated with the JWK below it. + // did:key:z4oJ8ceBqavcJf8ay4ZL1p6RB1NpZ7UkiqMzTAXXfd7hzCrezhU2spYik49RKtL12QgAYmJfT7AqFGMZqMyHW7geqejjD + // {"crv":"P-256","d":"2CG0StfWMvHHR0Su02Gc1e61nxLAOgTeIWwcipA8MlE","kty":"EC","x":"jys8wRkYPAqSaMo6bf5LKQCF9_Ji7RUVTwLy7Oj3V1M","y":"Kc8Yb2dQ_5xsaBtjSAjW9Tfk7O79go44ECoOYkrBvCA"} + jwkKey, err := jwk.ParseKey([]byte(`{"crv":"P-256","d":"2CG0StfWMvHHR0Su02Gc1e61nxLAOgTeIWwcipA8MlE","kty":"EC","x":"jys8wRkYPAqSaMo6bf5LKQCF9_Ji7RUVTwLy7Oj3V1M","y":"Kc8Yb2dQ_5xsaBtjSAjW9Tfk7O79go44ECoOYkrBvCA"}`)) + require.NoError(t, err) + var privateKey any + err = jwkKey.Raw(&privateKey) + require.NoError(t, err) + didKey := didsdk.DIDKey("did:key:z4oJ8ceBqavcJf8ay4ZL1p6RB1NpZ7UkiqMzTAXXfd7hzCrezhU2spYik49RKtL12QgAYmJfT7AqFGMZqMyHW7geqejjD") + + _, err = credService.CreateCredential(context.Background(), credential.CreateCredentialRequest{ + Issuer: issuerDID.DID.ID, + Subject: didKey.String(), + Data: map[string]any{ + "firstName": "Jack", + "lastName": "Dorsey", + }, + Expiry: time.Now().Add(24 * time.Hour).Format(time.RFC3339), + }) + require.NoError(t, err) + + hdrs := jws.NewHeaders() + require.NoError(t, hdrs.Set(jws.TypeKey, "openid4vci-proof+jwt")) + require.NoError(t, hdrs.Set(jws.KeyIDKey, didKey.String())) + + token := jwt.New() + require.NoError(t, token.Set(jwt.IssuerKey, didKey.String())) + require.NoError(t, token.Set(jwt.AudienceKey, "the credential issuer url")) + require.NoError(t, token.Set(jwt.IssuedAtKey, 1616689547)) + require.NoError(t, token.Set("nonce", "c_nonce from the issuer")) + + tokenData, err := json.Marshal(token) + require.NoError(t, err) + + data, err := jws.Sign(tokenData, jws.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(hdrs))) + require.NoError(t, err) + req := httptest.NewRequest( + http.MethodPost, + "https://ssi-service.com/v1/oidc/credentials", + strings.NewReader(`{ + "format": "jwt_vc_json", + "types": [ + "VerifiableCredential" + ], + "credentialSubject": { + "given_name": {}, + "last_name": {}, + "degree": {} + }, + "proof": { + "proof_type": "jwt", + "jwt":"`+string(data)+`" + } + }`)) + w := httptest.NewRecorder() + + err = oidcRouter.IssueCredential(newRequestContext(), w, req) + require.NoError(t, err) + + // On the first try, we expect an error with the c_nonce to use + var errorResp map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&errorResp)) + require.Equal(t, "invalid_or_missing_proof", errorResp["error"].(string)) + require.NotEmpty(t, errorResp["c_nonce"]) + + // Now do it all over + require.NoError(t, token.Set("nonce", errorResp["c_nonce"].(string))) + + tokenData, err = json.Marshal(token) + require.NoError(t, err) + + data, err = jws.Sign(tokenData, jws.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(hdrs))) + require.NoError(t, err) + + req = httptest.NewRequest( + http.MethodPost, + "https://ssi-service.com/v1/oidc/credentials", + strings.NewReader(`{ + "format": "jwt_vc_json", + "types": [ + "VerifiableCredential" + ], + "credentialSubject": { + "given_name": {}, + "last_name": {}, + "degree": {} + }, + "proof": { + "proof_type": "jwt", + "jwt":"`+string(data)+`" + } + }`)) + + err = oidcRouter.IssueCredential(newRequestContext(), w, req) + require.NoError(t, err) + + var credentialResponse model.CredentialResponse + require.NoError(t, json.NewDecoder(w.Body).Decode(&credentialResponse)) + + require.Equal(t, string(issuance.JWTVCJSON), credentialResponse.Format) + require.NotEmpty(t, credentialResponse.Credential) + require.NotEmpty(t, credentialResponse.CNonce) + require.Equal(t, 120, credentialResponse.CNonceExpiresIn) + + // And do it again, but after the expiration time. We should now expect an error + <-time.After(time.Duration(int64(errorResp["c_nonce_expires_in"].(float64)) * int64(time.Second))) + req = httptest.NewRequest( + http.MethodPost, + "https://ssi-service.com/v1/oidc/credentials", + strings.NewReader(`{ + "format": "jwt_vc_json", + "types": [ + "VerifiableCredential" + ], + "credentialSubject": { + "given_name": {}, + "last_name": {}, + "degree": {} + }, + "proof": { + "proof_type": "jwt", + "jwt":"`+string(data)+`" + } + }`)) + err = oidcRouter.IssueCredential(newRequestContext(), w, req) + require.NoError(t, err) + + var errResp2 map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&errResp2)) + require.Equal(t, "invalid_or_missing_proof", errResp2["error"].(string)) + require.NotEmpty(t, errResp2["c_nonce"]) + require.NotEqual(t, errorResp["c_nonce"], errResp2["c_nonce"]) +} + +func TestAuthService_CredentialEndpoint_ErrorResponses(t *testing.T) { + bolt := setupTestDB(t) + keyStoreService := testKeyStoreService(t, bolt) + didService := testDIDService(t, bolt, keyStoreService) + schemaService := testSchemaService(t, bolt, keyStoreService, didService) + credService := testCredentialService(t, bolt, keyStoreService, didService, schemaService) + oidcRouter, err := router.NewOIDCCredentialRouter(oidc.NewOIDCService( + didService.GetResolver(), + credService, + [32]byte{ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, + 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, + oidc.WithCNonceExpiresIn(12345*time.Second), + )) + require.NoError(t, err) + + jwkKey, err := jwk.ParseKey([]byte(`{"crv":"P-256","d":"2CG0StfWMvHHR0Su02Gc1e61nxLAOgTeIWwcipA8MlE","kty":"EC","x":"jys8wRkYPAqSaMo6bf5LKQCF9_Ji7RUVTwLy7Oj3V1M","y":"Kc8Yb2dQ_5xsaBtjSAjW9Tfk7O79go44ECoOYkrBvCA"}`)) + require.NoError(t, err) + var privateKey any + err = jwkKey.Raw(&privateKey) + require.NoError(t, err) + didKey := didsdk.DIDKey("did:key:z4oJ8ceBqavcJf8ay4ZL1p6RB1NpZ7UkiqMzTAXXfd7hzCrezhU2spYik49RKtL12QgAYmJfT7AqFGMZqMyHW7geqejjD") + + hdrs := jws.NewHeaders() + require.NoError(t, hdrs.Set(jws.TypeKey, "openid4vci-proof+jwt")) + require.NoError(t, hdrs.Set(jws.KeyIDKey, didKey.String())) + + token := jwt.New() + require.NoError(t, token.Set(jwt.IssuerKey, didKey.String())) + require.NoError(t, token.Set(jwt.AudienceKey, "the credential issuer url")) + require.NoError(t, token.Set(jwt.IssuedAtKey, 1616689547)) + + tokenData, err := json.Marshal(token) + require.NoError(t, err) + + data, err := jws.Sign(tokenData, jws.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(hdrs))) + require.NoError(t, err) + + t.Run("returns invalid_request when missing parameter", func(t *testing.T) { + req := httptest.NewRequest( + http.MethodPost, + "https://ssi-service.com/v1/oidc/credentials", + strings.NewReader(`{ + "types": [ + "VerifiableCredential" + ], + "credentialSubject": { + "given_name": {}, + "last_name": {}, + "degree": {} + }, + "proof": { + "proof_type": "jwt", + "jwt":"`+string(data)+`" + } + }`)) + w := httptest.NewRecorder() + err = oidcRouter.IssueCredential(newRequestContext(), w, req) + require.NoError(t, err) + + assertCredentialErrorResponseEquals(t, w, `{"error": "invalid_request"}`) + }) + + t.Run("returns unsupported_credential_format when requested format is not jwt_vc_json", func(t *testing.T) { + req := httptest.NewRequest( + http.MethodPost, + "https://ssi-service.com/v1/oidc/credentials", + strings.NewReader(`{ + "format": "my_own_format", + "types": [ + "VerifiableCredential" + ], + "credentialSubject": { + "given_name": {}, + "last_name": {}, + "degree": {} + }, + "proof": { + "proof_type": "jwt", + "jwt":"`+string(data)+`" + } + }`)) + w := httptest.NewRecorder() + err = oidcRouter.IssueCredential(newRequestContext(), w, req) + require.NoError(t, err) + + assertCredentialErrorResponseEquals(t, w, `{"error": "unsupported_credential_format"}`) + }) + + t.Run("returns invalid_or_missing_proof with c_nonce when nonce is missing", func(t *testing.T) { + req := httptest.NewRequest( + http.MethodPost, + "https://ssi-service.com/v1/oidc/credentials", + strings.NewReader(`{ + "format": "jwt_vc_json", + "types": [ + "VerifiableCredential" + ], + "credentialSubject": { + "given_name": {}, + "last_name": {}, + "degree": {} + }, + "proof": { + "proof_type": "jwt", + "jwt":"`+string(data)+`" + } + }`)) + w := httptest.NewRecorder() + err = oidcRouter.IssueCredential(newRequestContext(), w, req) + require.NoError(t, err) + + var resp map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.Equal(t, "invalid_or_missing_proof", resp["error"].(string)) + require.NotEmpty(t, resp["c_nonce"]) + require.Equal(t, 12345., resp["c_nonce_expires_in"].(float64)) + }) + + t.Run("returns invalid_or_missing_proof when proof used nonce different from provided", func(t *testing.T) { + require.NoError(t, token.Set("nonce", "my fake nonce")) + + tokenData, err := json.Marshal(token) + require.NoError(t, err) + + data, err := jws.Sign(tokenData, jws.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(hdrs))) + require.NoError(t, err) + + req := httptest.NewRequest( + http.MethodPost, + "https://ssi-service.com/v1/oidc/credentials", + strings.NewReader(`{ + "format": "jwt_vc_json", + "types": [ + "VerifiableCredential" + ], + "credentialSubject": { + "given_name": {}, + "last_name": {}, + "degree": {} + }, + "proof": { + "proof_type": "jwt", + "jwt":"`+string(data)+`" + } + }`)) + w := httptest.NewRecorder() + err = oidcRouter.IssueCredential(newRequestContext(), w, req) + require.NoError(t, err) + + var resp map[string]any + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.Equal(t, "invalid_or_missing_proof", resp["error"].(string)) + require.NotEmpty(t, resp["c_nonce"]) + require.Equal(t, 12345., resp["c_nonce_expires_in"].(float64)) + }) +} + +func assertCredentialErrorResponseEquals(t *testing.T, w *httptest.ResponseRecorder, s string) { + respBody, err := io.ReadAll(w.Body) + require.NoError(t, err) + require.JSONEq(t, s, string(respBody)) +} diff --git a/pkg/service/framework/framework.go b/pkg/service/framework/framework.go index ec19a486b..b548b1fe0 100644 --- a/pkg/service/framework/framework.go +++ b/pkg/service/framework/framework.go @@ -17,6 +17,7 @@ const ( Presentation Type = "presentation" Operation Type = "operation" Webhook Type = "webhook" + OIDC Type = "oidc" StatusReady StatusState = "ready" StatusNotReady StatusState = "not_ready" diff --git a/pkg/service/keystore/service.go b/pkg/service/keystore/service.go index 32ce7daad..8e61c4444 100644 --- a/pkg/service/keystore/service.go +++ b/pkg/service/keystore/service.go @@ -214,3 +214,8 @@ func (s Service) Sign(ctx context.Context, keyID string, data any) (*keyaccess.J } return schemaToken, nil } + +// GetServiceKey returns the service key from either memory or the DB. +func (s Service) GetServiceKey(ctx context.Context) ([]byte, error) { + return s.storage.getAndSetServiceKey(ctx) +} diff --git a/pkg/service/oidc/model/credential_request.go b/pkg/service/oidc/model/credential_request.go new file mode 100644 index 000000000..5e7082603 --- /dev/null +++ b/pkg/service/oidc/model/credential_request.go @@ -0,0 +1,53 @@ +package model + +import "github.com/TBD54566975/ssi-sdk/oidc/issuance" + +// CredentialRequest represents a request for a credential. +type CredentialRequest struct { + // Format is the required format of the credential to be issued. + Format issuance.Format `json:"format"` + + // Proof is an optional proof of possession of the key material. + Proof *ProofParameter `json:"proof"` + + // Present when format==jwt_vc_json + *JWTVCCredentialRequest +} + +// JWTProof objects contain a single jwt element with a JWS [RFC7515] as proof of possession. The JWT MUST contain the following elements: +// +// in the JOSE Header, +// +// - typ: REQUIRED. MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. +// - alg: REQUIRED. A digital signature algorithm identifier such as per IANA "JSON Web Signature and Encryption Algorithms" registry. MUST NOT be none or an identifier for a symmetric algorithm (MAC). +// - kid: CONDITIONAL. JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. MUST NOT be present if jwk or x5c is present. +// - jwk: CONDITIONAL. JOSE Header containing the key material the new Credential shall be bound to. MUST NOT be present if kid or x5c is present. +// - x5c: CONDITIONAL. JOSE Header containing a certificate or certificate chain corresponding to the key used to sign the JWT. This element MAY be used to convey a key attestation. In such a case, the actual key certificate will contain attributes related to the key properties. MUST NOT be present if kid or jwk is present. +// +// in the JWT body, +// +// - iss: OPTIONAL (string). The value of this claim MUST be the client_id of the client making the credential request. This claim MUST be omitted if the Access Token authorizing the issuance call was obtained from a Pre-Authorized Code Flow through anonymous access to the Token Endpoint. +// - aud: REQUIRED (string). The value of this claim MUST be the Credential Issuer URL of the Credential Issuer. +// - iat: REQUIRED (number). The value of this claim MUST be the time at which the proof was issued using the syntax defined in [RFC7519]. +// - nonce: REQUIRED (string). The value type of this claim MUST be a string, where the value is a c_nonce provided by the Credential Issuer. +type JWTProof struct { + JWT string `json:"jwt"` +} + +// ProofParameter represents a proof object. +type ProofParameter struct { + // ProofType is the required concrete proof type. Currently, the only possible value is "jwt". + ProofType string `json:"proof_type"` + + // Present when proof_type == "jwt". + *JWTProof +} + +type JWTVCCredentialRequest struct { + // Types is a list of credential types. The credential issued by the issuer MUST at least contain the + // values listed in this claim. At least `VerifiableCredential` must be specified. + Types []string `json:"types"` + + // This object determines the optional claims to be added to the credential to be issued. + CredentialSubject map[string]any `json:"credentialSubject,omitempty"` +} diff --git a/pkg/service/oidc/model/credential_request_test.go b/pkg/service/oidc/model/credential_request_test.go new file mode 100644 index 000000000..1d269dfa3 --- /dev/null +++ b/pkg/service/oidc/model/credential_request_test.go @@ -0,0 +1,21 @@ +package model + +import ( + _ "embed" + "testing" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/require" +) + +//go:embed expected_jwt_vc_credential_request.json +var exampleJWTVCCredentialRequest []byte + +func TestCredentialRequestUnmarshalAndMarshallIsLossless(t *testing.T) { + var m CredentialRequest + require.NoError(t, json.Unmarshal(exampleJWTVCCredentialRequest, &m)) + + jsonData, err := json.Marshal(m) + require.NoError(t, err) + require.JSONEq(t, string(exampleJWTVCCredentialRequest), string(jsonData)) +} diff --git a/pkg/service/oidc/model/credential_response.go b/pkg/service/oidc/model/credential_response.go new file mode 100644 index 000000000..5a711a36c --- /dev/null +++ b/pkg/service/oidc/model/credential_response.go @@ -0,0 +1,23 @@ +package model + +// CredentialResponse represents a response from a Credential Issuer to a Credential Request. +type CredentialResponse struct { + // format: REQUIRED. JSON string denoting the format of the issued Credential. + Format string `json:"format"` + + // credential: OPTIONAL. Contains issued Credential. MUST be present when acceptance_token is not returned. + // MAY be a JSON string or a JSON object, depending on the Credential format. See Appendix E for the Credential format specific encoding requirements. + Credential string `json:"credential,omitempty"` + + // acceptance_token: OPTIONAL. A JSON string containing a security token subsequently used to obtain a Credential. + // MUST be present when credential is not returned. + AcceptanceToken string `json:"acceptance_token,omitempty"` + + // c_nonce: OPTIONAL. JSON string containing a nonce to be used to create a proof of possession of key material when requesting a Credential (see Section 7.2). + // When received, the Wallet MUST use this nonce value for its subsequent credential requests until the Credential Issuer provides a fresh nonce. + CNonce string `json:"c_nonce,omitempty"` + + // c_nonce_expires_in: OPTIONAL. JSON integer denoting the lifetime in seconds of the c_nonce. + // Note that this is an integer, not a string, as specified in the text. + CNonceExpiresIn int `json:"c_nonce_expires_in,omitempty"` +} diff --git a/pkg/service/oidc/model/credential_response_test.go b/pkg/service/oidc/model/credential_response_test.go new file mode 100644 index 000000000..e428ea9f4 --- /dev/null +++ b/pkg/service/oidc/model/credential_response_test.go @@ -0,0 +1,21 @@ +package model + +import ( + _ "embed" + "testing" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/require" +) + +//go:embed expected_jwt_jvc_credential_response.json +var exampleJWTVCCredentialResp []byte + +func TestCredentialResponseUnmarshalAndMarshallIsLossless(t *testing.T) { + var m CredentialResponse + require.NoError(t, json.Unmarshal(exampleJWTVCCredentialResp, &m)) + + jsonData, err := json.Marshal(m) + require.NoError(t, err) + require.JSONEq(t, string(exampleJWTVCCredentialResp), string(jsonData)) +} diff --git a/pkg/service/oidc/model/expected_jwt_jvc_credential_response.json b/pkg/service/oidc/model/expected_jwt_jvc_credential_response.json new file mode 100644 index 000000000..3262670c2 --- /dev/null +++ b/pkg/service/oidc/model/expected_jwt_jvc_credential_response.json @@ -0,0 +1,6 @@ +{ + "format": "jwt_vc_json", + "credential": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2YyI6eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL2V4YW1wbGVzL3YxIl0sImlkIjoiaHR0cDovL2V4YW1wbGUuZWR1L2NyZWRlbnRpYWxzLzM3MzIiLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIiwiVW5pdmVyc2l0eURlZ3JlZUNyZWRlbnRpYWwiXSwiaXNzdWVyIjoiaHR0cHM6Ly9leGFtcGxlLmVkdS9pc3N1ZXJzLzU2NTA0OSIsImlzc3VhbmNlRGF0ZSI6IjIwMTAtMDEtMDFUMDA6MDA6MDBaIiwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEiLCJkZWdyZWUiOnsidHlwZSI6IkJhY2hlbG9yRGVncmVlIiwibmFtZSI6IkJhY2hlbG9yIG9mIFNjaWVuY2UgYW5kIEFydHMifX19LCJpc3MiOiJodHRwczovL2V4YW1wbGUuZWR1L2lzc3VlcnMvNTY1MDQ5IiwibmJmIjoxMjYyMzA0MDAwLCJqdGkiOiJodHRwOi8vZXhhbXBsZS5lZHUvY3JlZGVudGlhbHMvMzczMiIsInN1YiI6ImRpZDpleGFtcGxlOmViZmViMWY3MTJlYmM2ZjFjMjc2ZTEyZWMyMSJ9.z5vgMTK1nfizNCg5N-niCOL3WUIAL7nXy-nGhDZYO_-PNGeE-0djCpWAMH8fD8eWSID5PfkPBYkx_dfLJnQ7NA", + "c_nonce": "fGFF7UkhLa", + "c_nonce_expires_in": 86400 +} \ No newline at end of file diff --git a/pkg/service/oidc/model/expected_jwt_vc_credential_request.json b/pkg/service/oidc/model/expected_jwt_vc_credential_request.json new file mode 100644 index 000000000..29e689a9c --- /dev/null +++ b/pkg/service/oidc/model/expected_jwt_vc_credential_request.json @@ -0,0 +1,16 @@ +{ + "format": "jwt_vc_json", + "types": [ + "VerifiableCredential", + "UniversityDegreeCredential" + ], + "credentialSubject": { + "given_name": {}, + "last_name": {}, + "degree": {} + }, + "proof": { + "proof_type": "jwt", + "jwt":"eyJraWQiOiJkaWQ6ZXhhbXBsZTplYmZlYjFmNzEyZWJjNmYxYzI3NmUxMmVjMjEva2V5cy8xIiwiYWxnIjoiRVMyNTYiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJzNkJoZFJrcXQzIiwiYXVkIjoiaHR0cHM6Ly9zZXJ2ZXIuZXhhbXBsZS5jb20iLCJpYXQiOiIyMDE4LTA5LTE0VDIxOjE5OjEwWiIsIm5vbmNlIjoidFppZ25zbkZicCJ9.ewdkIkPV50iOeBUqMXCC_aZKPxgihac0aW9EkL1nOzM" + } +} \ No newline at end of file diff --git a/pkg/service/oidc/service.go b/pkg/service/oidc/service.go new file mode 100644 index 000000000..db31bd212 --- /dev/null +++ b/pkg/service/oidc/service.go @@ -0,0 +1,296 @@ +package oidc + +import ( + "context" + "crypto/rand" + "net/http" + "time" + + didsdk "github.com/TBD54566975/ssi-sdk/did" + "github.com/TBD54566975/ssi-sdk/oidc/issuance" + "github.com/TBD54566975/ssi-sdk/util" + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/pkg/errors" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "github.com/sirupsen/logrus" + credint "github.com/tbd54566975/ssi-service/internal/credential" + "github.com/tbd54566975/ssi-service/internal/did" + "github.com/tbd54566975/ssi-service/internal/keyaccess" + "github.com/tbd54566975/ssi-service/pkg/server/framework" + "github.com/tbd54566975/ssi-service/pkg/service/credential" + svcframework "github.com/tbd54566975/ssi-service/pkg/service/framework" + "github.com/tbd54566975/ssi-service/pkg/service/oidc/model" +) + +const DefaultCNonceExpiration = 5 * time.Second + +type Service struct { + // key to use for generating nonce values. + key *otp.Key + cNonceExpiresIn time.Duration + + resolver didsdk.Resolver + credService *credential.Service +} + +var _ svcframework.Service = (*Service)(nil) + +func (s Service) Type() svcframework.Type { + return svcframework.OIDC +} + +func (s Service) Status() svcframework.Status { + return svcframework.Status{Status: svcframework.StatusReady} +} + +type Option func(*Service) + +func WithCNonceExpiresIn(d time.Duration) Option { + return func(service *Service) { + service.cNonceExpiresIn = d + } +} + +func WithOTPKey(k *otp.Key) Option { + return func(service *Service) { + service.key = k + } +} + +func NewOIDCService(didResolver didsdk.Resolver, service *credential.Service, serviceKey [32]byte, opts ...Option) *Service { + s := &Service{ + cNonceExpiresIn: DefaultCNonceExpiration, + resolver: didResolver, + credService: service, + } + for _, o := range opts { + o(s) + } + if s.key == nil { + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "ssi-service", + AccountName: "internal", + Period: uint(s.cNonceExpiresIn / time.Second), + SecretSize: 32, + Digits: 16, + Rand: rand.Reader, + Algorithm: otp.AlgorithmSHA512, + Secret: serviceKey[:], + }) + if err != nil { + panic(err) + } + WithOTPKey(key)(s) + } + + return s +} + +func (s Service) CredentialEndpoint(ctx context.Context, credRequest *model.CredentialRequest) (*model.CredentialResponse, error) { + if credRequest.Format == "" { + return nil, framework.NewRequestErrorMsg("invalid_request", http.StatusBadRequest) + } + if credRequest.Format != issuance.JWTVCJSON { + return nil, framework.NewRequestErrorMsg("unsupported_credential_format", http.StatusBadRequest) + } + if credRequest.Proof == nil { + return nil, errors.New("proof is required") + } + + var subject string + var err error + switch credRequest.Proof.ProofType { + case "jwt": + subject, err = s.processProof(ctx, credRequest.Proof) + if err != nil { + return nil, errors.Wrap(err, "processing proof") + } + default: + return nil, errors.New("proof_type not recognized") + } + + serviceResp, err := s.credService.GetCredentialsBySubject(ctx, credential.GetCredentialBySubjectRequest{Subject: subject}) + if err != nil { + return nil, err + } + + toIssue, err := findCredentialToIssue(serviceResp, credRequest) + if err != nil { + return nil, errors.Wrap(err, "finding credentials") + } + + const DefaultDuration = 120 * time.Second + + return &model.CredentialResponse{ + Format: string(credRequest.Format), + Credential: string(*toIssue.CredentialJWT), + CNonce: uuid.NewString(), + CNonceExpiresIn: int(DefaultDuration / time.Second), + }, nil +} + +func findCredentialToIssue(serviceResp *credential.GetCredentialsResponse, credRequest *model.CredentialRequest) (*credint.Container, error) { + for _, c := range serviceResp.Credentials { + types, err := util.InterfaceToStrings(c.Credential.Type) + if err != nil { + return nil, errors.Wrap(err, "converting interfaces to strings") + } + if sameElements(types, credRequest.Types) { + return &c, nil + } + } + return nil, errors.New("no credential found") +} + +func sameElements(arr1, arr2 []string) bool { + if len(arr1) != len(arr2) { + return false + } + + // Create a map to count the frequency of each element in arr1 + count := make(map[string]int) + for _, s := range arr1 { + count[s]++ + } + + // Check if each element in arr2 exists in the count map + for _, s := range arr2 { + if count[s] == 0 { + return false + } + count[s]-- + } + + return true +} + +var ( + ErrNonceNotPresent = errors.New("nonce not present in token") + ErrNonceNotString = errors.New("nonce should be a string") + ErrNonceDifferent = errors.New("nonce different from expected") +) + +func (s Service) processProof(ctx context.Context, proof *model.ProofParameter) (string, error) { + // The Credential Issuer MUST validate that the proof is actually signed by a key identified in the JOSE Header + message, err := jws.ParseString(proof.JWT) + if err != nil { + return "", errors.Wrap(err, "parsing JWT") + } + + if len(message.Signatures()) != 1 { + return "", errors.New("jwt expected to have exactly one signature") + } + headers := message.Signatures()[0].ProtectedHeaders() + + // - typ: REQUIRED. MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. + const openID4VCIType = "openid4vci-proof+jwt" + if headers.Type() != openID4VCIType { + return "", errors.Errorf("typ must be set to %q", openID4VCIType) + } + // - alg: REQUIRED. A digital signature algorithm identifier such as per IANA "JSON Web Signature and Encryption Algorithms" registry. MUST NOT be none or an identifier for a symmetric algorithm (MAC). + allowedAlgs := map[jwa.SignatureAlgorithm]struct{}{ + jwa.ES256: {}, + jwa.ES256K: {}, + jwa.ES384: {}, + jwa.ES512: {}, + jwa.EdDSA: {}, + jwa.PS256: {}, + jwa.PS384: {}, + jwa.PS512: {}, + jwa.RS256: {}, + jwa.RS384: {}, + jwa.RS512: {}, + } + alg, ok := allowedAlgs[headers.Algorithm()] + if !ok { + return "", errors.Errorf("alg %q is not allowed", alg) + } + + // Only one of kid, jwk, or x5c can be present. + kid := headers.KeyID() + headerJWK := headers.JWK() + certChain := headers.X509CertChain() + + if !((kid != "") != (headerJWK != nil) != (certChain != nil)) { + return "", errors.New("exactly one of kid, jwk, or x5c must be present") + } + + // We'll do verification later, once we've established that the nonce is one we produced. + token, err := jwt.ParseString(proof.JWT, jwt.WithVerify(false)) + if err != nil { + return "", errors.Wrap(err, "parsing jwt") + } + + nonceRaw, ok := token.Get("nonce") + if !ok { + return "", ErrNonceNotPresent + } + nonce, ok := nonceRaw.(string) + if !ok { + return "", ErrNonceNotString + } + if !s.isNonceValid(nonce) { + return "", ErrNonceDifferent + } + + if kid != "" { + // - kid: CONDITIONAL. JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers + // to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to. + // MUST NOT be present if jwk or x5c is present. + + if err := did.VerifyTokenFromDID(ctx, s.resolver, kid, keyaccess.JWT(proof.JWT)); err != nil { + return "", errors.Wrap(err, "verifying") + } + return kid, nil + } + + if headerJWK != nil { + // - jwk: CONDITIONAL. JOSE Header containing the key material the new Credential shall be bound to. MUST NOT be + // present if kid or x5c is present. + return "", util.NotImplementedError + } + + if certChain != nil { + // - x5c: CONDITIONAL. JOSE Header containing a certificate or certificate chain corresponding to the key used to + // sign the JWT. This element MAY be used to convey a key attestation. In such a case, the actual key certificate + // will contain attributes related to the key properties. MUST NOT be present if kid or jwk is present. + return "", util.NotImplementedError + } + + // The Credential Issuer MUST validate that the proof is actually signed by a key identified in the JOSE Header. + return "", errors.New("unreachable") +} + +func (s Service) isNonceValid(nonce string) bool { + valid, err := totp.ValidateCustom(nonce, s.key.Secret(), time.Now(), s.validateOpts()) + if err != nil { + logrus.WithError(err).Error("Problem validating") + } + return valid +} + +// CurrentNonce returns a time based one time password as defined in RFC 6238. It is meant to be used +func (s Service) CurrentNonce() (string, error) { + passcode, err := totp.GenerateCodeCustom(s.key.Secret(), time.Now(), s.validateOpts()) + if err != nil { + return "", errors.Wrap(err, "generating code") + } + return passcode, nil +} + +// NonceExpiresIn returns the number of seconds until the current nonce expires. +func (s Service) NonceExpiresIn() int { + return int(s.cNonceExpiresIn / time.Second) +} + +func (s Service) validateOpts() totp.ValidateOpts { + return totp.ValidateOpts{ + Period: uint(s.key.Period()), + Digits: 16, + Algorithm: s.key.Algorithm(), + } +} diff --git a/pkg/service/service.go b/pkg/service/service.go index 26626158b..b63895b4c 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -1,6 +1,7 @@ package service import ( + "context" "fmt" "github.com/tbd54566975/ssi-service/config" @@ -11,6 +12,7 @@ import ( "github.com/tbd54566975/ssi-service/pkg/service/issuing" "github.com/tbd54566975/ssi-service/pkg/service/keystore" "github.com/tbd54566975/ssi-service/pkg/service/manifest" + "github.com/tbd54566975/ssi-service/pkg/service/oidc" "github.com/tbd54566975/ssi-service/pkg/service/operation" "github.com/tbd54566975/ssi-service/pkg/service/presentation" "github.com/tbd54566975/ssi-service/pkg/service/schema" @@ -135,5 +137,18 @@ func instantiateServices(config config.ServicesConfig) ([]framework.Service, err return nil, util.LoggingErrorMsg(err, "could not instantiate the operation service") } - return []framework.Service{keyStoreService, didService, schemaService, issuingService, credentialService, manifestService, presentationService, operationService, webhookService}, nil + opts := make([]oidc.Option, 0) + + if config.OIDCCredentialConfig.CNonceExpiresIn != nil { + opts = append(opts, oidc.WithCNonceExpiresIn(*config.OIDCCredentialConfig.CNonceExpiresIn)) + } + serviceKey, err := keyStoreService.GetServiceKey(context.Background()) + if err != nil { + return nil, util.LoggingErrorMsg(err, "could not get service key") + } + var serviceKeyArr [32]byte + copy(serviceKeyArr[:], serviceKey) + oidcService := oidc.NewOIDCService(didResolver, credentialService, serviceKeyArr, opts...) + + return []framework.Service{keyStoreService, didService, schemaService, issuingService, credentialService, manifestService, presentationService, operationService, webhookService, oidcService}, nil } diff --git a/pkg/testutil/setup.go b/pkg/testutil/setup.go index 59fb58fce..00104fc5d 100644 --- a/pkg/testutil/setup.go +++ b/pkg/testutil/setup.go @@ -1,9 +1,13 @@ package testutil import ( + "context" "os" + "time" "github.com/TBD54566975/ssi-sdk/schema" + "github.com/google/uuid" + "github.com/tbd54566975/ssi-service/pkg/server/framework" ) func EnableSchemaCaching() { @@ -19,3 +23,12 @@ func EnableSchemaCaching() { } l.EnableHTTPCache() } + +// NewRequestContext construct a context value as expected by our handlers +func NewRequestContext() context.Context { + return context.WithValue(context.Background(), framework.KeyRequestState, &framework.RequestState{ + TraceID: uuid.New().String(), + Now: time.Now(), + StatusCode: 1, + }) +}