diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 0c7f8813a9..af8cc8d397 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -208,19 +208,63 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ } func (r Wrapper) Callback(ctx context.Context, request CallbackRequestObject) (CallbackResponseObject, error) { - // check id in path - _, err := r.toOwnedDID(ctx, request.Did) + // validate request + // check did in path + ownDID, err := r.toOwnedDID(ctx, request.Did) if err != nil { - // this is an OAuthError already, will be rendered as 400 but that's fine (for now) for an illegal id return nil, err } + // check if state is present and resolves to a client state + if request.Params.State == nil || *request.Params.State == "" { + // without state it is an invalid request, but try to provide as much useful information as possible + if request.Params.Error != nil && *request.Params.Error != "" { + callbackError := callbackRequestToError(request, nil) + callbackError.InternalError = errors.New("missing state parameter") + return nil, callbackError + } + return nil, oauthError(oauth.InvalidRequest, "missing state parameter") + } + oauthSession := new(OAuthSession) + if err = r.oauthClientStateStore().Get(*request.Params.State, oauthSession); err != nil { + return nil, oauthError(oauth.InvalidRequest, "invalid or expired state", err) + } + if !ownDID.Equals(*oauthSession.OwnDID) { + // TODO: this is a manipulated request, add error logging? + return nil, withCallbackURI(oauthError(oauth.InvalidRequest, "session DID does not match request"), oauthSession.redirectURI()) + } + + // if error is present, redirect error back to application initiating the flow + if request.Params.Error != nil && *request.Params.Error != "" { + return nil, callbackRequestToError(request, oauthSession.redirectURI()) + } - // if error is present, delegate call to error handler - if request.Params.Error != nil { - return r.handleCallbackError(request) + // check if code is present + if request.Params.Code == nil || *request.Params.Code == "" { + return nil, withCallbackURI(oauthError(oauth.InvalidRequest, "missing code parameter"), oauthSession.redirectURI()) } - return r.handleCallback(ctx, request) + // continue flow + switch oauthSession.ClientFlow { + case credentialRequestClientFlow: + return r.handleOpenID4VCICallback(ctx, *request.Params.Code, oauthSession) + case accessTokenRequestClientFlow: + return r.handleCallback(ctx, *request.Params.Code, oauthSession) + default: + // programming error, should never happen + return nil, withCallbackURI(oauthError(oauth.ServerError, "unknown client flow for callback: '"+oauthSession.ClientFlow+"'"), oauthSession.redirectURI()) + } +} + +// callbackRequestToError should only be used if request.params.Error is present +func callbackRequestToError(request CallbackRequestObject, redirectURI *url.URL) oauth.OAuth2Error { + requestErr := oauth.OAuth2Error{ + Code: oauth.ErrorCode(*request.Params.Error), + RedirectURI: redirectURI, + } + if request.Params.ErrorDescription != nil { + requestErr.Description = *request.Params.ErrorDescription + } + return requestErr } func (r Wrapper) RetrieveAccessToken(_ context.Context, request RetrieveAccessTokenRequestObject) (RetrieveAccessTokenResponseObject, error) { diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 995a461b6e..73b1811d7a 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -389,12 +389,11 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { // handleAuthorizeRequestFromVerifier _ = ctx.client.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthClientStateKey...).Put("state", OAuthSession{ // this is the state from the holder that was stored at the creation of the first authorization request to the verifier - ClientID: holderDID.String(), - Scope: "test", - OwnDID: &holderDID, - ClientState: "state", - RedirectURI: "https://example.com/iam/holder/cb", - ResponseType: "code", + ClientID: holderDID.String(), + Scope: "test", + OwnDID: &holderDID, + ClientState: "state", + RedirectURI: "https://example.com/iam/holder/cb", }) _ = ctx.client.userSessionStore().Put("session-id", UserSession{ TenantDID: holderDID, @@ -461,22 +460,25 @@ func TestWrapper_Callback(t *testing.T) { errorDescription := "error description" state := "state" token := "token" + redirectURI, parseErr := url.Parse("https://example.com/iam/holder/cb") + require.NoError(t, parseErr) session := OAuthSession{ + ClientFlow: "access_token_request", SessionID: "token", OwnDID: &holderDID, - RedirectURI: "https://example.com/iam/holder/cb", - VerifierDID: &verifierDID, + RedirectURI: redirectURI.String(), + OtherDID: &verifierDID, TokenEndpoint: "https://example.com/token", } t.Run("ok - error flow", func(t *testing.T) { ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) putState(ctx, "state", session) res, err := ctx.client.Callback(nil, CallbackRequestObject{ - Did: webDID.String(), + Did: holderDID.String(), Params: CallbackParams{ State: &state, Error: &errorCode, @@ -484,8 +486,14 @@ func TestWrapper_Callback(t *testing.T) { }, }) - require.NoError(t, err) - assert.Equal(t, "https://example.com/iam/holder/cb?error=error&error_description=error+description", res.(Callback302Response).Headers.Location) + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.OAuth2Error{ + Code: oauth.ErrorCode(errorCode), + Description: errorDescription, + RedirectURI: redirectURI, + }, err) + assert.Nil(t, res) }) t.Run("ok - success flow", func(t *testing.T) { ctx := newTestClient(t) @@ -494,11 +502,11 @@ func TestWrapper_Callback(t *testing.T) { putState(ctx, "state", withDPoP) putToken(ctx, token) codeVerifier := getState(ctx, state).PKCEParams.Verifier - ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil).Times(2) - ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, "https://example.com/oauth2/did:web:example.com:iam:123/callback", holderDID, codeVerifier, true).Return(&oauth.TokenResponse{AccessToken: "access"}, nil) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, "https://example.com/oauth2/did:web:example.com:iam:holder/callback", holderDID, codeVerifier, true).Return(&oauth.TokenResponse{AccessToken: "access"}, nil) res, err := ctx.client.Callback(nil, CallbackRequestObject{ - Did: webDID.String(), + Did: holderDID.String(), Params: CallbackParams{ Code: &code, State: &state, @@ -518,21 +526,22 @@ func TestWrapper_Callback(t *testing.T) { t.Run("ok - no DPoP", func(t *testing.T) { ctx := newTestClient(t) _ = ctx.client.oauthClientStateStore().Put(state, OAuthSession{ + ClientFlow: "access_token_request", OwnDID: &holderDID, PKCEParams: generatePKCEParams(), RedirectURI: "https://example.com/iam/holder/cb", SessionID: "token", UseDPoP: false, - VerifierDID: &verifierDID, + OtherDID: &verifierDID, TokenEndpoint: session.TokenEndpoint, }) putToken(ctx, token) codeVerifier := getState(ctx, state).PKCEParams.Verifier - ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil).Times(2) - ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, "https://example.com/oauth2/did:web:example.com:iam:123/callback", holderDID, codeVerifier, false).Return(&oauth.TokenResponse{AccessToken: "access"}, nil) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, "https://example.com/oauth2/did:web:example.com:iam:holder/callback", holderDID, codeVerifier, false).Return(&oauth.TokenResponse{AccessToken: "access"}, nil) res, err := ctx.client.Callback(nil, CallbackRequestObject{ - Did: webDID.String(), + Did: holderDID.String(), Params: CallbackParams{ Code: &code, State: &state, @@ -542,17 +551,108 @@ func TestWrapper_Callback(t *testing.T) { require.NoError(t, err) assert.NotNil(t, res) }) - t.Run("unknown did", func(t *testing.T) { + t.Run("err - unknown did", func(t *testing.T) { ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(false, nil) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(false, nil) res, err := ctx.client.Callback(nil, CallbackRequestObject{ - Did: webDID.String(), + Did: holderDID.String(), }) assert.EqualError(t, err, "DID document not managed by this node") assert.Nil(t, res) }) + t.Run("err - did mismatch", func(t *testing.T) { + ctx := newTestClient(t) + putState(ctx, "state", session) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) + + res, err := ctx.client.Callback(nil, CallbackRequestObject{ + Did: webDID.String(), + Params: CallbackParams{ + Code: &code, + State: &state, + }, + }) + + assert.Nil(t, res) + requireOAuthError(t, err, oauth.InvalidRequest, "session DID does not match request") + + }) + t.Run("err - missing state", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) + + _, err := ctx.client.Callback(nil, CallbackRequestObject{ + Did: holderDID.String(), + Params: CallbackParams{ + Code: &code, + }, + }) + + requireOAuthError(t, err, oauth.InvalidRequest, "missing state parameter") + }) + t.Run("err - error flow but missing state", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) + + _, err := ctx.client.Callback(nil, CallbackRequestObject{ + Did: holderDID.String(), + Params: CallbackParams{ + Error: &errorCode, + ErrorDescription: &errorDescription, + }, + }) + + requireOAuthError(t, err, oauth.ErrorCode(errorCode), errorDescription) + assert.EqualError(t, err, "error - missing state parameter - error description") + }) + t.Run("err - expired state/session", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) + + _, err := ctx.client.Callback(nil, CallbackRequestObject{ + Did: webDID.String(), + Params: CallbackParams{ + Code: &code, + State: &state, + }, + }) + + requireOAuthError(t, err, oauth.InvalidRequest, "invalid or expired state") + }) + t.Run("err - missing code", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) + putState(ctx, "state", session) + + _, err := ctx.client.Callback(nil, CallbackRequestObject{ + Did: holderDID.String(), + Params: CallbackParams{ + State: &state, + }, + }) + + requireOAuthError(t, err, oauth.InvalidRequest, "missing code parameter") + }) + t.Run("err - unknown flow", func(t *testing.T) { + ctx := newTestClient(t) + _ = ctx.client.oauthClientStateStore().Put(state, OAuthSession{ + ClientFlow: "", + OwnDID: &holderDID, + }) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) + + _, err := ctx.client.Callback(nil, CallbackRequestObject{ + Did: holderDID.String(), + Params: CallbackParams{ + Code: &code, + State: &state, + }, + }) + + requireOAuthError(t, err, oauth.ServerError, "unknown client flow for callback: ''") + }) } func TestWrapper_RetrieveAccessToken(t *testing.T) { diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 73de8bdb0d..e9f0fc72d7 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -189,23 +189,8 @@ type Cnf struct { Jkt string `json:"jkt"` } -// CallbackOid4vciCredentialIssuanceParams defines parameters for CallbackOid4vciCredentialIssuance. -type CallbackOid4vciCredentialIssuanceParams struct { - // Code The oauth2 code response. - Code string `form:"code" json:"code"` - - // State The oauth2 state, required as the authorize request sends it. - State string `form:"state" json:"state"` - - // Error The error code. - Error *string `form:"error,omitempty" json:"error,omitempty"` - - // ErrorDescription The error description. - ErrorDescription *string `form:"error_description,omitempty" json:"error_description,omitempty"` -} - -// RequestOid4vciCredentialIssuanceJSONBody defines parameters for RequestOid4vciCredentialIssuance. -type RequestOid4vciCredentialIssuanceJSONBody struct { +// RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. +type RequestOpenid4VCICredentialIssuanceJSONBody struct { AuthorizationDetails []map[string]interface{} `json:"authorization_details"` Issuer string `json:"issuer"` @@ -286,8 +271,8 @@ type ValidateDPoPProofJSONRequestBody = DPoPValidateRequest // CreateDPoPProofJSONRequestBody defines body for CreateDPoPProof for application/json ContentType. type CreateDPoPProofJSONRequestBody = DPoPRequest -// RequestOid4vciCredentialIssuanceJSONRequestBody defines body for RequestOid4vciCredentialIssuance for application/json ContentType. -type RequestOid4vciCredentialIssuanceJSONRequestBody RequestOid4vciCredentialIssuanceJSONBody +// RequestOpenid4VCICredentialIssuanceJSONRequestBody defines body for RequestOpenid4VCICredentialIssuance for application/json ContentType. +type RequestOpenid4VCICredentialIssuanceJSONRequestBody RequestOpenid4VCICredentialIssuanceJSONBody // RequestServiceAccessTokenJSONRequestBody defines body for RequestServiceAccessToken for application/json ContentType. type RequestServiceAccessTokenJSONRequestBody = ServiceAccessTokenRequest @@ -546,9 +531,6 @@ type ServerInterface interface { // Get the OAuth2 Authorization Server metadata for a did:web with a :iam: path. // (GET /.well-known/oauth-authorization-server/iam/{id}) OAuthAuthorizationServerMetadata(ctx echo.Context, id string) error - // Callback for the Oid4VCI credential issuance flow. - // (GET /iam/oid4vci/callback) - CallbackOid4vciCredentialIssuance(ctx echo.Context, params CallbackOid4vciCredentialIssuanceParams) error // Returns the did:web DID for the specified tenant. // (GET /iam/{id}/did.json) GetTenantWebDID(ctx echo.Context, id string) error @@ -566,7 +548,7 @@ type ServerInterface interface { CreateDPoPProof(ctx echo.Context, did string) error // Start the Oid4VCI authorization flow. // (POST /internal/auth/v2/{did}/request-credential) - RequestOid4vciCredentialIssuance(ctx echo.Context, did string) error + RequestOpenid4VCICredentialIssuance(ctx echo.Context, did string) error // Start the authorization flow to get an access token from a remote authorization server. // (POST /internal/auth/v2/{did}/request-service-access-token) RequestServiceAccessToken(ctx echo.Context, did string) error @@ -647,47 +629,6 @@ func (w *ServerInterfaceWrapper) OAuthAuthorizationServerMetadata(ctx echo.Conte return err } -// CallbackOid4vciCredentialIssuance converts echo context to params. -func (w *ServerInterfaceWrapper) CallbackOid4vciCredentialIssuance(ctx echo.Context) error { - var err error - - ctx.Set(JwtBearerAuthScopes, []string{}) - - // Parameter object where we will unmarshal all parameters from the context - var params CallbackOid4vciCredentialIssuanceParams - // ------------- Required query parameter "code" ------------- - - err = runtime.BindQueryParameter("form", true, true, "code", ctx.QueryParams(), ¶ms.Code) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter code: %s", err)) - } - - // ------------- Required query parameter "state" ------------- - - err = runtime.BindQueryParameter("form", true, true, "state", ctx.QueryParams(), ¶ms.State) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter state: %s", err)) - } - - // ------------- Optional query parameter "error" ------------- - - err = runtime.BindQueryParameter("form", true, false, "error", ctx.QueryParams(), ¶ms.Error) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter error: %s", err)) - } - - // ------------- Optional query parameter "error_description" ------------- - - err = runtime.BindQueryParameter("form", true, false, "error_description", ctx.QueryParams(), ¶ms.ErrorDescription) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter error_description: %s", err)) - } - - // Invoke the callback with all the unmarshaled arguments - err = w.Handler.CallbackOid4vciCredentialIssuance(ctx, params) - return err -} - // GetTenantWebDID converts echo context to params. func (w *ServerInterfaceWrapper) GetTenantWebDID(ctx echo.Context) error { var err error @@ -761,8 +702,8 @@ func (w *ServerInterfaceWrapper) CreateDPoPProof(ctx echo.Context) error { return err } -// RequestOid4vciCredentialIssuance converts echo context to params. -func (w *ServerInterfaceWrapper) RequestOid4vciCredentialIssuance(ctx echo.Context) error { +// RequestOpenid4VCICredentialIssuance converts echo context to params. +func (w *ServerInterfaceWrapper) RequestOpenid4VCICredentialIssuance(ctx echo.Context) error { var err error // ------------- Path parameter "did" ------------- var did string @@ -772,7 +713,7 @@ func (w *ServerInterfaceWrapper) RequestOid4vciCredentialIssuance(ctx echo.Conte ctx.Set(JwtBearerAuthScopes, []string{}) // Invoke the callback with all the unmarshaled arguments - err = w.Handler.RequestOid4vciCredentialIssuance(ctx, did) + err = w.Handler.RequestOpenid4VCICredentialIssuance(ctx, did) return err } @@ -1051,13 +992,12 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/.well-known/did.json", wrapper.GetRootWebDID) router.GET(baseURL+"/.well-known/oauth-authorization-server", wrapper.RootOAuthAuthorizationServerMetadata) router.GET(baseURL+"/.well-known/oauth-authorization-server/iam/:id", wrapper.OAuthAuthorizationServerMetadata) - router.GET(baseURL+"/iam/oid4vci/callback", wrapper.CallbackOid4vciCredentialIssuance) router.GET(baseURL+"/iam/:id/did.json", wrapper.GetTenantWebDID) router.POST(baseURL+"/internal/auth/v2/accesstoken/introspect", wrapper.IntrospectAccessToken) router.GET(baseURL+"/internal/auth/v2/accesstoken/:sessionID", wrapper.RetrieveAccessToken) router.POST(baseURL+"/internal/auth/v2/dpop_validate", wrapper.ValidateDPoPProof) router.POST(baseURL+"/internal/auth/v2/:did/dpop", wrapper.CreateDPoPProof) - router.POST(baseURL+"/internal/auth/v2/:did/request-credential", wrapper.RequestOid4vciCredentialIssuance) + router.POST(baseURL+"/internal/auth/v2/:did/request-credential", wrapper.RequestOpenid4VCICredentialIssuance) router.POST(baseURL+"/internal/auth/v2/:did/request-service-access-token", wrapper.RequestServiceAccessToken) router.POST(baseURL+"/internal/auth/v2/:did/request-user-access-token", wrapper.RequestUserAccessToken) router.GET(baseURL+"/oauth2/:did/authorize", wrapper.HandleAuthorizeRequest) @@ -1171,49 +1111,6 @@ func (response OAuthAuthorizationServerMetadatadefaultApplicationProblemPlusJSON return json.NewEncoder(w).Encode(response.Body) } -type CallbackOid4vciCredentialIssuanceRequestObject struct { - Params CallbackOid4vciCredentialIssuanceParams -} - -type CallbackOid4vciCredentialIssuanceResponseObject interface { - VisitCallbackOid4vciCredentialIssuanceResponse(w http.ResponseWriter) error -} - -type CallbackOid4vciCredentialIssuance302ResponseHeaders struct { - Location string -} - -type CallbackOid4vciCredentialIssuance302Response struct { - Headers CallbackOid4vciCredentialIssuance302ResponseHeaders -} - -func (response CallbackOid4vciCredentialIssuance302Response) VisitCallbackOid4vciCredentialIssuanceResponse(w http.ResponseWriter) error { - w.Header().Set("Location", fmt.Sprint(response.Headers.Location)) - w.WriteHeader(302) - return nil -} - -type CallbackOid4vciCredentialIssuancedefaultApplicationProblemPlusJSONResponse struct { - Body struct { - // Detail A human-readable explanation specific to this occurrence of the problem. - Detail string `json:"detail"` - - // Status HTTP statuscode - Status float32 `json:"status"` - - // Title A short, human-readable summary of the problem type. - Title string `json:"title"` - } - StatusCode int -} - -func (response CallbackOid4vciCredentialIssuancedefaultApplicationProblemPlusJSONResponse) VisitCallbackOid4vciCredentialIssuanceResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(response.StatusCode) - - return json.NewEncoder(w).Encode(response.Body) -} - type GetTenantWebDIDRequestObject struct { Id string `json:"id"` } @@ -1366,25 +1263,25 @@ func (response CreateDPoPProof401Response) VisitCreateDPoPProofResponse(w http.R return nil } -type RequestOid4vciCredentialIssuanceRequestObject struct { +type RequestOpenid4VCICredentialIssuanceRequestObject struct { Did string `json:"did"` - Body *RequestOid4vciCredentialIssuanceJSONRequestBody + Body *RequestOpenid4VCICredentialIssuanceJSONRequestBody } -type RequestOid4vciCredentialIssuanceResponseObject interface { - VisitRequestOid4vciCredentialIssuanceResponse(w http.ResponseWriter) error +type RequestOpenid4VCICredentialIssuanceResponseObject interface { + VisitRequestOpenid4VCICredentialIssuanceResponse(w http.ResponseWriter) error } -type RequestOid4vciCredentialIssuance200JSONResponse RedirectResponse +type RequestOpenid4VCICredentialIssuance200JSONResponse RedirectResponse -func (response RequestOid4vciCredentialIssuance200JSONResponse) VisitRequestOid4vciCredentialIssuanceResponse(w http.ResponseWriter) error { +func (response RequestOpenid4VCICredentialIssuance200JSONResponse) VisitRequestOpenid4VCICredentialIssuanceResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type RequestOid4vciCredentialIssuancedefaultApplicationProblemPlusJSONResponse struct { +type RequestOpenid4VCICredentialIssuancedefaultApplicationProblemPlusJSONResponse struct { Body struct { // Detail A human-readable explanation specific to this occurrence of the problem. Detail string `json:"detail"` @@ -1398,7 +1295,7 @@ type RequestOid4vciCredentialIssuancedefaultApplicationProblemPlusJSONResponse s StatusCode int } -func (response RequestOid4vciCredentialIssuancedefaultApplicationProblemPlusJSONResponse) VisitRequestOid4vciCredentialIssuanceResponse(w http.ResponseWriter) error { +func (response RequestOpenid4VCICredentialIssuancedefaultApplicationProblemPlusJSONResponse) VisitRequestOpenid4VCICredentialIssuanceResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(response.StatusCode) @@ -1843,9 +1740,6 @@ type StrictServerInterface interface { // Get the OAuth2 Authorization Server metadata for a did:web with a :iam: path. // (GET /.well-known/oauth-authorization-server/iam/{id}) OAuthAuthorizationServerMetadata(ctx context.Context, request OAuthAuthorizationServerMetadataRequestObject) (OAuthAuthorizationServerMetadataResponseObject, error) - // Callback for the Oid4VCI credential issuance flow. - // (GET /iam/oid4vci/callback) - CallbackOid4vciCredentialIssuance(ctx context.Context, request CallbackOid4vciCredentialIssuanceRequestObject) (CallbackOid4vciCredentialIssuanceResponseObject, error) // Returns the did:web DID for the specified tenant. // (GET /iam/{id}/did.json) GetTenantWebDID(ctx context.Context, request GetTenantWebDIDRequestObject) (GetTenantWebDIDResponseObject, error) @@ -1863,7 +1757,7 @@ type StrictServerInterface interface { CreateDPoPProof(ctx context.Context, request CreateDPoPProofRequestObject) (CreateDPoPProofResponseObject, error) // Start the Oid4VCI authorization flow. // (POST /internal/auth/v2/{did}/request-credential) - RequestOid4vciCredentialIssuance(ctx context.Context, request RequestOid4vciCredentialIssuanceRequestObject) (RequestOid4vciCredentialIssuanceResponseObject, error) + RequestOpenid4VCICredentialIssuance(ctx context.Context, request RequestOpenid4VCICredentialIssuanceRequestObject) (RequestOpenid4VCICredentialIssuanceResponseObject, error) // Start the authorization flow to get an access token from a remote authorization server. // (POST /internal/auth/v2/{did}/request-service-access-token) RequestServiceAccessToken(ctx context.Context, request RequestServiceAccessTokenRequestObject) (RequestServiceAccessTokenResponseObject, error) @@ -1982,31 +1876,6 @@ func (sh *strictHandler) OAuthAuthorizationServerMetadata(ctx echo.Context, id s return nil } -// CallbackOid4vciCredentialIssuance operation middleware -func (sh *strictHandler) CallbackOid4vciCredentialIssuance(ctx echo.Context, params CallbackOid4vciCredentialIssuanceParams) error { - var request CallbackOid4vciCredentialIssuanceRequestObject - - request.Params = params - - handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.CallbackOid4vciCredentialIssuance(ctx.Request().Context(), request.(CallbackOid4vciCredentialIssuanceRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "CallbackOid4vciCredentialIssuance") - } - - response, err := handler(ctx, request) - - if err != nil { - return err - } else if validResponse, ok := response.(CallbackOid4vciCredentialIssuanceResponseObject); ok { - return validResponse.VisitCallbackOid4vciCredentialIssuanceResponse(ctx.Response()) - } else if response != nil { - return fmt.Errorf("unexpected response type: %T", response) - } - return nil -} - // GetTenantWebDID operation middleware func (sh *strictHandler) GetTenantWebDID(ctx echo.Context, id string) error { var request GetTenantWebDIDRequestObject @@ -2150,31 +2019,31 @@ func (sh *strictHandler) CreateDPoPProof(ctx echo.Context, did string) error { return nil } -// RequestOid4vciCredentialIssuance operation middleware -func (sh *strictHandler) RequestOid4vciCredentialIssuance(ctx echo.Context, did string) error { - var request RequestOid4vciCredentialIssuanceRequestObject +// RequestOpenid4VCICredentialIssuance operation middleware +func (sh *strictHandler) RequestOpenid4VCICredentialIssuance(ctx echo.Context, did string) error { + var request RequestOpenid4VCICredentialIssuanceRequestObject request.Did = did - var body RequestOid4vciCredentialIssuanceJSONRequestBody + var body RequestOpenid4VCICredentialIssuanceJSONRequestBody if err := ctx.Bind(&body); err != nil { return err } request.Body = &body handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.RequestOid4vciCredentialIssuance(ctx.Request().Context(), request.(RequestOid4vciCredentialIssuanceRequestObject)) + return sh.ssi.RequestOpenid4VCICredentialIssuance(ctx.Request().Context(), request.(RequestOpenid4VCICredentialIssuanceRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "RequestOid4vciCredentialIssuance") + handler = middleware(handler, "RequestOpenid4VCICredentialIssuance") } response, err := handler(ctx, request) if err != nil { return err - } else if validResponse, ok := response.(RequestOid4vciCredentialIssuanceResponseObject); ok { - return validResponse.VisitRequestOid4vciCredentialIssuanceResponse(ctx.Response()) + } else if validResponse, ok := response.(RequestOpenid4VCICredentialIssuanceResponseObject); ok { + return validResponse.VisitRequestOpenid4VCICredentialIssuanceResponse(ctx.Response()) } else if response != nil { return fmt.Errorf("unexpected response type: %T", response) } diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 338cf4ca52..1b6cfd968d 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -25,21 +25,25 @@ import ( "fmt" "net/http" "net/url" + "time" - "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" - "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" nutsHttp "github.com/nuts-foundation/nuts-node/http" - "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) -func (r Wrapper) RequestOid4vciCredentialIssuance(ctx context.Context, request RequestOid4vciCredentialIssuanceRequestObject) (RequestOid4vciCredentialIssuanceResponseObject, error) { +var timeFunc = time.Now + +// jwtTypeOpenID4VCIProof defines the OpenID4VCI JWT-subtype (used as typ claim in the JWT). +const jwtTypeOpenID4VCIProof = "openid4vci-proof+jwt" + +func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, request RequestOpenid4VCICredentialIssuanceRequestObject) (RequestOpenid4VCICredentialIssuanceResponseObject, error) { if request.Body == nil { // why did oapi-codegen generate a pointer for the body?? return nil, core.InvalidInputError("missing request body") @@ -79,24 +83,21 @@ func (r Wrapper) RequestOid4vciCredentialIssuance(ctx context.Context, request R pkceParams := generatePKCEParams() // Figure out our own redirect URL by parsing the did:web and extracting the host. - requesterDidUrl, err := didweb.DIDToURL(*requestHolder) + redirectUri, err := createOAuth2BaseURL(*requestHolder) if err != nil { - return nil, fmt.Errorf("failed convert did (%s) to url: %w", requestHolder.String(), err) - } - redirectUri, err := url.Parse(fmt.Sprintf("https://%s/iam/oid4vci/callback", requesterDidUrl.Host)) - if err != nil { - return nil, fmt.Errorf("failed to create the url for host: %w", err) + return nil, fmt.Errorf("failed to create callback URL for verification: %w", err) } + redirectUri = redirectUri.JoinPath(oauth.CallbackPath) // Store the session - err = r.openid4vciSessionStore().Put(state, &Oid4vciSession{ - HolderDid: requestHolder, - IssuerDid: issuerDid, - RemoteRedirectUri: request.Body.RedirectUri, - RedirectUri: redirectUri.String(), - PKCEParams: pkceParams, + err = r.oauthClientStateStore().Put(state, &OAuthSession{ + ClientFlow: credentialRequestClientFlow, + OwnDID: requestHolder, + OtherDID: issuerDid, + RedirectURI: request.Body.RedirectUri, + PKCEParams: pkceParams, // OpenID4VCI issuers may use multiple Authorization Servers // We must use the token_endpoint that corresponds to the same Authorization Server used for the authorization_endpoint - IssuerTokenEndpoint: authzServerMetadata.TokenEndpoint, + TokenEndpoint: authzServerMetadata.TokenEndpoint, IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, }) if err != nil { @@ -111,99 +112,89 @@ func (r Wrapper) RequestOid4vciCredentialIssuance(ctx context.Context, request R oauth.ResponseTypeParam: oauth.CodeResponseType, oauth.StateParam: state, oauth.ClientIDParam: requestHolder.String(), + oauth.ClientIDSchemeParam: didClientIDScheme, oauth.AuthorizationDetailsParam: string(authorizationDetails), oauth.RedirectURIParam: redirectUri.String(), oauth.CodeChallengeParam: pkceParams.Challenge, oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, }) - return RequestOid4vciCredentialIssuance200JSONResponse{ + return RequestOpenid4VCICredentialIssuance200JSONResponse{ RedirectURI: redirectUrl.String(), }, nil } -func (r Wrapper) CallbackOid4vciCredentialIssuance(ctx context.Context, request CallbackOid4vciCredentialIssuanceRequestObject) (CallbackOid4vciCredentialIssuanceResponseObject, error) { - state := request.Params.State - oid4vciSession := Oid4vciSession{} - err := r.openid4vciSessionStore().Get(state, &oid4vciSession) - if err != nil { - return nil, core.NotFoundError("Cannot locate active session for state: %s", state) - } - if request.Params.Error != nil { - errorCode := oauth.ErrorCode(*request.Params.Error) - errorDescription := "" - if request.Params.ErrorDescription != nil { - errorDescription = *request.Params.ErrorDescription - } else { - errorDescription = fmt.Sprintf("Issuer returned error code: %s", *request.Params.Error) - } - return nil, withCallbackURI(oauthError(errorCode, errorDescription), oid4vciSession.remoteRedirectUri()) - } - code := request.Params.Code - pkceParams := oid4vciSession.PKCEParams - issuerDid := oid4vciSession.IssuerDid - holderDid := oid4vciSession.HolderDid - tokenEndpoint := oid4vciSession.IssuerTokenEndpoint - credentialEndpoint := oid4vciSession.IssuerCredentialEndpoint +func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode string, oauthSession *OAuthSession) (CallbackResponseObject, error) { + // extract callback URI at calling app from OAuthSession + // this is the URI where the user-agent will be redirected to + appCallbackURI := oauthSession.redirectURI() + + checkURL, err := createOAuth2BaseURL(*oauthSession.OwnDID) if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("cannot fetch the right endpoints: %s", err.Error())), oid4vciSession.remoteRedirectUri()) + return nil, fmt.Errorf("failed to create callback URL for verification: %w", err) } - response, err := r.auth.IAMClient().AccessToken(ctx, code, tokenEndpoint, oid4vciSession.RedirectUri, *holderDid, pkceParams.Verifier, false) + checkURL = checkURL.JoinPath(oauth.CallbackPath) + + // use code to request access token from remote token endpoint + response, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnDID, oauthSession.PKCEParams.Verifier, false) if err != nil { - return nil, withCallbackURI(oauthError(oauth.AccessDenied, fmt.Sprintf("error while fetching the access_token from endpoint: %s, error: %s", tokenEndpoint, err.Error())), oid4vciSession.remoteRedirectUri()) + return nil, withCallbackURI(oauthError(oauth.AccessDenied, fmt.Sprintf("error while fetching the access_token from endpoint: %s, error: %s", oauthSession.TokenEndpoint, err.Error())), appCallbackURI) } - cNonce := response.Get(oauth.CNonceParam) - proofJWT, err := r.proofJwt(ctx, *holderDid, *issuerDid, &cNonce) + + // make proof and collect credential + proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, *oauthSession.OtherDID, response.Get(oauth.CNonceParam)) if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error building proof to fetch the credential from endpoint %s, error: %s", credentialEndpoint, err.Error())), oid4vciSession.remoteRedirectUri()) + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error building proof to fetch the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) } - credentials, err := r.auth.IAMClient().VerifiableCredentials(ctx, credentialEndpoint, response.AccessToken, proofJWT) + credentials, err := r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, response.AccessToken, proofJWT) if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", credentialEndpoint, err.Error())), oid4vciSession.remoteRedirectUri()) + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI) } + // validate credential + // TODO: check that issued credential is bound to DID that requested it (OwnDID)??? credential, err := vc.ParseVerifiableCredential(credentials.Credential) if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentials.Credential, err.Error())), oid4vciSession.remoteRedirectUri()) + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentials.Credential, err.Error())), appCallbackURI) } err = r.vcr.Verifier().Verify(*credential, true, true, nil) if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while verifying the credential from issuer: %s, error: %s", credential.Issuer.String(), err.Error())), oid4vciSession.remoteRedirectUri()) + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while verifying the credential from issuer: %s, error: %s", credential.Issuer.String(), err.Error())), appCallbackURI) } + // store credential in wallet err = r.vcr.Wallet().Put(ctx, *credential) if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while storing credential with id: %s, error: %s", credential.ID, err.Error())), oid4vciSession.remoteRedirectUri()) + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while storing credential with id: %s, error: %s", credential.ID, err.Error())), appCallbackURI) } - - log.Logger().Debugf("stored the credential with id: %s, now redirecting to %s", credential.ID, oid4vciSession.RemoteRedirectUri) - - return CallbackOid4vciCredentialIssuance302Response{ - Headers: CallbackOid4vciCredentialIssuance302ResponseHeaders{Location: oid4vciSession.RemoteRedirectUri}, + return Callback302Response{ + Headers: Callback302ResponseHeaders{Location: appCallbackURI.String()}, }, nil } -func (r *Wrapper) proofJwt(ctx context.Context, holderDid did.DID, audienceDid did.DID, nonce *string) (string, error) { - // TODO: is this the right key type? - kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.NutsSigningKeyType) +func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audienceDid did.DID, nonce string) (string, error) { + kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.AssertionMethod) if err != nil { return "", fmt.Errorf("failed to resolve key for did (%s): %w", holderDid.String(), err) } - jti, _ := uuid.NewUUID() + headers := map[string]interface{}{ + "typ": jwtTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. + "kid": kid.String(), // 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. + } + audURL, err := didweb.DIDToURL(audienceDid) + if err != nil { + // can't fail or would have failed before + return "", err + } claims := map[string]interface{}{ - "iss": holderDid.String(), - "aud": audienceDid.String(), - "jti": jti.String(), + jwt.IssuerKey: holderDid.String(), + jwt.AudienceKey: audURL.String(), // Credential Issuer Identifier (did:web URL) + jwt.IssuedAtKey: timeFunc().Unix(), } - if nonce != nil { - claims["nonce"] = nonce + if nonce != "" { + claims[oauth.NonceParam] = nonce } - proofJwt, err := r.jwtSigner.SignJWT(ctx, claims, nil, kid.String()) + proofJwt, err := r.jwtSigner.SignJWT(ctx, claims, headers, kid.String()) if err != nil { return "", fmt.Errorf("failed to sign the JWT with kid (%s): %w", kid.String(), err) } return proofJwt, nil } - -// openid4vciSessionStore is used by the Client to keep track of OpenID4VCI requests -func (r Wrapper) openid4vciSessionStore() storage.SessionStore { - return r.storageEngine.GetSessionDatabase().GetStore(oid4vciSessionValidity, "openid4vci") -} diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index bd0846689a..52107a0b06 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -19,10 +19,11 @@ package iam import ( + "context" "errors" - "fmt" "net/url" "testing" + "time" "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -35,7 +36,7 @@ import ( "go.uber.org/mock/gomock" ) -func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { +func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { redirectURI := "https://test.test/iam/123/cb" authServer := "https://auth.server/" metadata := oauth.OpenIDCredentialIssuerMetadata{ @@ -53,23 +54,23 @@ func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - response, err := ctx.client.RequestOid4vciCredentialIssuance(nil, RequestOid4vciCredentialIssuanceRequestObject{ + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, RequestOpenid4VCICredentialIssuanceRequestObject{ Did: holderDID.String(), - Body: &RequestOid4vciCredentialIssuanceJSONRequestBody{ + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "format": "vc+sd-jwt"}}, Issuer: issuerDID.String(), RedirectUri: redirectURI, }, }) require.NoError(t, err) - require.NotNil(t, response) - redirectUri, err := url.Parse(response.(RequestOid4vciCredentialIssuance200JSONResponse).RedirectURI) + require.NotNil(t, response) //RequestOid4vciCredentialIssuanceResponseObject + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) require.NoError(t, err) assert.Equal(t, "auth.server", redirectUri.Host) assert.Equal(t, "/authorize", redirectUri.Path) assert.True(t, redirectUri.Query().Has("state")) assert.True(t, redirectUri.Query().Has("code_challenge")) - assert.Equal(t, "https://example.com/iam/oid4vci/callback", redirectUri.Query().Get("redirect_uri")) + assert.Equal(t, "https://example.com/oauth2/did:web:example.com:iam:holder/callback", redirectUri.Query().Get("redirect_uri")) assert.Equal(t, holderDID.String(), redirectUri.Query().Get("client_id")) assert.Equal(t, "S256", redirectUri.Query().Get("code_challenge_method")) assert.Equal(t, "code", redirectUri.Query().Get("response_type")) @@ -88,7 +89,7 @@ func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerURL).Return(nil, assert.AnError) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) @@ -99,13 +100,13 @@ func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(nil, assert.AnError) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerURL).Return(nil, assert.AnError) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) t.Run("error - did not owned by this node", func(t *testing.T) { ctx := newTestClient(t) ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(false, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) require.Error(t, err) assert.EqualError(t, err, "requester DID: DID document not managed by this node") }) @@ -113,7 +114,7 @@ func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { ctx := newTestClient(t) ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(nil, assert.AnError) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) assert.ErrorIs(t, err, assert.AnError) }) }) @@ -123,7 +124,7 @@ func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { ctx := newTestClient(t) ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, req) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) assert.EqualError(t, err, "could not parse Issuer DID: not-a-did: invalid DID") }) @@ -136,7 +137,7 @@ func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, req) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) assert.ErrorContains(t, err, "URL does not represent a Web DID\nunsupported DID method: nuts") }) @@ -146,7 +147,7 @@ func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { ctx := newTestClient(t) ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, req) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) assert.ErrorContains(t, err, "invalid issuer: URL does not represent a Web DID\nunsupported DID method: nuts") }) @@ -159,7 +160,7 @@ func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { TokenEndpoint: "https://auth.server/token"} ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&invalidAuthzMetadata, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) assert.EqualError(t, err, "failed to parse the authorization_endpoint: parse \":\": missing protocol scheme") }) @@ -170,7 +171,7 @@ func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { metadata.CredentialEndpoint = "" ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) assert.EqualError(t, err, "no credential_endpoint found") }) t.Run("error - missing authorization_endpoint", func(t *testing.T) { @@ -180,7 +181,7 @@ func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { authzMetadata.AuthorizationEndpoint = "" ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) assert.EqualError(t, err, "no authorization_endpoint found") }) t.Run("error - missing token_endpoint", func(t *testing.T) { @@ -190,23 +191,23 @@ func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { authzMetadata.TokenEndpoint = "" ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) assert.EqualError(t, err, "no token_endpoint found") }) } -func requestCredentials(holderDID did.DID, issuerDID did.DID, redirectURI string) RequestOid4vciCredentialIssuanceRequestObject { - return RequestOid4vciCredentialIssuanceRequestObject{ +func requestCredentials(holderDID did.DID, issuerDID did.DID, redirectURI string) RequestOpenid4VCICredentialIssuanceRequestObject { + return RequestOpenid4VCICredentialIssuanceRequestObject{ Did: holderDID.String(), - Body: &RequestOid4vciCredentialIssuanceJSONRequestBody{ + Body: &RequestOpenid4VCICredentialIssuanceJSONRequestBody{ Issuer: issuerDID.String(), RedirectUri: redirectURI, }, } } -func TestWrapper_CallbackOid4vciCredentialIssuance(t *testing.T) { - redirectURI := "https://test.test/iam/123/cb" +func TestWrapper_handleOpenID4VCICallback(t *testing.T) { + redirectURI := "https://example.com/oauth2/did:web:example.com:iam:holder/callback" authServer := "https://auth.server" tokenEndpoint := authServer + "/token" cNonce := crypto.GenerateNonce() @@ -218,83 +219,61 @@ func TestWrapper_CallbackOid4vciCredentialIssuance(t *testing.T) { verifiableCredential := createIssuerCredential(issuerDID, holderDID) redirectUrl := "https://client.service/issuance_is_done" - session := Oid4vciSession{ - HolderDid: &holderDID, - IssuerDid: &issuerDID, - RemoteRedirectUri: redirectUrl, - RedirectUri: redirectURI, + session := OAuthSession{ + ClientFlow: "openid4vci_credential_request", + OwnDID: &holderDID, + OtherDID: &issuerDID, + RedirectURI: redirectUrl, PKCEParams: pkceParams, - IssuerTokenEndpoint: tokenEndpoint, + TokenEndpoint: tokenEndpoint, IssuerCredentialEndpoint: credEndpoint, } tokenResponse := oauth.NewTokenResponse(accessToken, "Bearer", 0, "").With("c_nonce", cNonce) credentialResponse := iam.CredentialResponse{ - Format: "jwt_vc", Credential: verifiableCredential.Raw(), } + now := time.Now() + timeFunc = func() time.Time { return now } + defer func() { timeFunc = time.Now }() t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) + require.NoError(t, ctx.client.oauthClientStateStore().Put(state, &session)) + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) - ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { + assert.Equal(t, map[string]interface{}{"typ": "openid4vci-proof+jwt", "kid": "kid"}, headers) + expectedClaims := map[string]interface{}{ + "iss": holderDID.String(), + "aud": issuerURL, // must be the URL, not the DID + "iat": timeFunc().Unix(), + "nonce": cNonce, + } + assert.Equal(t, expectedClaims, claims) + return "signed-proof", nil + }) ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) ctx.wallet.EXPECT().Put(nil, *verifiableCredential) - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, + callback, err := ctx.client.Callback(nil, CallbackRequestObject{ + Did: holderDID.String(), + Params: CallbackParams{ + Code: ptrTo(code), + State: ptrTo(state), }, }) require.NoError(t, err) assert.NotNil(t, callback) - actual := callback.(CallbackOid4vciCredentialIssuance302Response) + actual := callback.(Callback302Response) assert.Equal(t, redirectUrl, actual.Headers.Location) }) - t.Run("error_on_redirect", func(t *testing.T) { - ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) - errorCode := "failed" - errorDesc := "errorDesc" - - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: "", - State: state, - Error: &errorCode, - ErrorDescription: &errorDesc, - }, - }) - - require.Error(t, err) - assert.Nil(t, callback) - assert.Equal(t, fmt.Sprintf("%s - %s", errorCode, errorDesc), err.Error()) - }) - t.Run("no_session", func(t *testing.T) { - ctx := newTestClient(t) - - _, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) - require.Error(t, err) - }) t.Run("fail_access_token", func(t *testing.T) { ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) assert.Error(t, err) assert.Nil(t, callback) @@ -302,74 +281,61 @@ func TestWrapper_CallbackOid4vciCredentialIssuance(t *testing.T) { }) t.Run("fail_credential_response", func(t *testing.T) { ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(nil, errors.New("FAIL")) - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + + assert.Nil(t, callback) + assert.EqualError(t, err, "server_error - error while fetching the credential from endpoint https://auth.server/credz, error: FAIL") + }) + t.Run("err - invalid credential", func(t *testing.T) { + ctx := newTestClient(t) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&iam.CredentialResponse{ + Credential: "super invalid", + }, nil) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) - assert.Error(t, err) assert.Nil(t, callback) - assert.Equal(t, "server_error - error while fetching the credential from endpoint https://auth.server/credz, error: FAIL", err.Error()) + assert.EqualError(t, err, "server_error - error while parsing the credential: super invalid, error: invalid JWT") }) t.Run("fail_verify", func(t *testing.T) { ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil).Return(errors.New("FAIL")) - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) - assert.Error(t, err) + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) + assert.Nil(t, callback) - assert.Equal(t, "server_error - error while verifying the credential from issuer: did:web:example.com:iam:issuer, error: FAIL", err.Error()) + assert.EqualError(t, err, "server_error - error while verifying the credential from issuer: did:web:example.com:iam:issuer, error: FAIL") }) t.Run("error - key not found", func(t *testing.T) { ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.URI{}, nil, resolver.ErrKeyNotFound) - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) - assert.Error(t, err) assert.Nil(t, callback) assert.ErrorContains(t, err, "failed to resolve key for did (did:web:example.com:iam:holder): "+resolver.ErrKeyNotFound.Error()) }) t.Run("error - signature failure", func(t *testing.T) { ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("signature failed")) - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) - assert.Error(t, err) assert.Nil(t, callback) assert.ErrorContains(t, err, "failed to sign the JWT with kid (kid): signature failed") }) diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 14f2470a9f..a579ba2421 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -745,65 +745,21 @@ func (r Wrapper) handleAccessTokenRequest(ctx context.Context, request HandleTok return HandleTokenRequest200JSONResponse(*response), nil } -func (r Wrapper) handleCallbackError(request CallbackRequestObject) (CallbackResponseObject, error) { - // we know error is not empty - code := *request.Params.Error - var description string - if request.Params.ErrorDescription != nil { - description = *request.Params.ErrorDescription - } - - // check if the state param is present and if we have a client state for it - var oauthSession OAuthSession - if request.Params.State != nil { - _ = r.oauthClientStateStore().Get(*request.Params.State, &oauthSession) - // we use the redirectURI from the oauthSession to redirect the user back to its own error page - if oauthSession.redirectURI() != nil { - // add code and description - location := httpNuts.AddQueryParams(*oauthSession.redirectURI(), map[string]string{ - oauth.ErrorParam: code, - oauth.ErrorDescriptionParam: description, - }) - return Callback302Response{ - Headers: Callback302ResponseHeaders{Location: location.String()}, - }, nil - } - } - // we don't have a client state, so we can't redirect to the holder redirectURI - // return an error page instead - return nil, oauthError(oauth.ErrorCode(code), description) -} - -func (r Wrapper) handleCallback(ctx context.Context, request CallbackRequestObject) (CallbackResponseObject, error) { - // check if state is present and resolves to a client state - var oauthSession OAuthSession - // return early with an OAuthError if state is nil - if request.Params.State == nil { - return nil, oauthError(oauth.InvalidRequest, "missing state parameter") - } - // lookup client state - if err := r.oauthClientStateStore().Get(*request.Params.State, &oauthSession); err != nil { - return nil, oauthError(oauth.InvalidRequest, "invalid or expired state", err) - } +func (r Wrapper) handleCallback(ctx context.Context, authorizationCode string, oauthSession *OAuthSession) (CallbackResponseObject, error) { // extract callback URI at calling app from OAuthSession // this is the URI where the user-agent will be redirected to appCallbackURI := oauthSession.redirectURI() - // check if code is present - if request.Params.Code == nil { - return nil, withCallbackURI(oauthError(oauth.InvalidRequest, "missing code parameter"), appCallbackURI) - } // send callback URL for verification (this method is the handler for that URL) to authorization server to check against earlier redirect_uri // we call it checkURL here because it is used by the authorization server to check if the code is valid - requestHolder, _ := r.toOwnedDID(ctx, request.Did) // already checked - checkURL, err := createOAuth2BaseURL(*requestHolder) + checkURL, err := createOAuth2BaseURL(*oauthSession.OwnDID) if err != nil { return nil, fmt.Errorf("failed to create callback URL for verification: %w", err) } checkURL = checkURL.JoinPath(oauth.CallbackPath) // use code to request access token from remote token endpoint - tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, *request.Params.Code, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnDID, oauthSession.PKCEParams.Verifier, oauthSession.UseDPoP) + tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnDID, oauthSession.PKCEParams.Verifier, oauthSession.UseDPoP) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("failed to retrieve access token: %s", err.Error())), appCallbackURI) } diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index b500dbd413..5b23a57b68 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -188,7 +188,7 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { SessionID: "token", OwnDID: &holderDID, RedirectURI: "https://example.com/iam/holder/cb", - VerifierDID: &verifierDID, + OtherDID: &verifierDID, } userSession := UserSession{ TenantDID: holderDID, @@ -753,69 +753,23 @@ func Test_handleAccessTokenRequest(t *testing.T) { func Test_handleCallback(t *testing.T) { code := "code" - state := "state" session := OAuthSession{ SessionID: "token", OwnDID: &holderDID, RedirectURI: "https://example.com/iam/holder/cb", - VerifierDID: &verifierDID, + OtherDID: &verifierDID, PKCEParams: generatePKCEParams(), TokenEndpoint: "https://example.com/token", } - - t.Run("err - missing state", func(t *testing.T) { - ctx := newTestClient(t) - - _, err := ctx.client.handleCallback(nil, CallbackRequestObject{ - Did: webDID.String(), - Params: CallbackParams{ - Code: &code, - }, - }) - - requireOAuthError(t, err, oauth.InvalidRequest, "missing state parameter") - }) - t.Run("err - expired state", func(t *testing.T) { - ctx := newTestClient(t) - - _, err := ctx.client.handleCallback(nil, CallbackRequestObject{ - Did: webDID.String(), - Params: CallbackParams{ - Code: &code, - State: &state, - }, - }) - - requireOAuthError(t, err, oauth.InvalidRequest, "invalid or expired state") - }) - t.Run("err - missing code", func(t *testing.T) { + t.Run("err - failed to retrieve access token", func(t *testing.T) { ctx := newTestClient(t) - putState(ctx, "state", session) + callbackURI := "https://example.com/oauth2/" + holderDID.String() + "/callback" + codeVerifier := session.PKCEParams.Verifier - _, err := ctx.client.handleCallback(nil, CallbackRequestObject{ - Did: webDID.String(), - Params: CallbackParams{ - State: &state, - }, - }) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, callbackURI, holderDID, codeVerifier, false).Return(nil, assert.AnError) - requireOAuthError(t, err, oauth.InvalidRequest, "missing code parameter") - }) - t.Run("err - failed to retrieve access token", func(t *testing.T) { - ctx := newTestClient(t) - putState(ctx, "state", session) - codeVerifier := getState(ctx, state).PKCEParams.Verifier - ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) - ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, "https://example.com/oauth2/"+webDID.String()+"/callback", holderDID, codeVerifier, false).Return(nil, assert.AnError) - - _, err := ctx.client.handleCallback(nil, CallbackRequestObject{ - Did: webDID.String(), - Params: CallbackParams{ - Code: &code, - State: &state, - }, - }) + _, err := ctx.client.handleCallback(nil, code, &session) requireOAuthError(t, err, oauth.ServerError, "failed to retrieve access token: assert.AnError general error for testing") }) diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index f39ea5455d..b02a5a991c 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -34,19 +34,31 @@ import ( // The client state (and nonce/redirectToken as well) is used to refer to this session. // Both the client and the server use this session to store information about the request. type OAuthSession struct { - ClientID string `json:"client_id,omitempty"` - ClientState string `json:"client_state,omitempty"` - OpenID4VPVerifier *PEXConsumer `json:"openid4vp_verifier,omitempty"` - OwnDID *did.DID `json:"own_did,omitempty"` - PKCEParams PKCEParams `json:"pkce_params"` - RedirectURI string `json:"redirect_uri,omitempty"` - ResponseType string `json:"response_type,omitempty"` - Scope string `json:"scope,omitempty"` - SessionID string `json:"session_id,omitempty"` - TokenEndpoint string `json:"token_endpoint,omitempty"` - UseDPoP bool `json:"use_dpop,omitempty"` - VerifierDID *did.DID `json:"verifier_did,omitempty"` -} + ClientFlow oauthClientFlow `json:"client_flow,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientState string `json:"client_state,omitempty"` + OpenID4VPVerifier *PEXConsumer `json:"openid4vp_verifier,omitempty"` + OwnDID *did.DID `json:"own_did,omitempty"` + OtherDID *did.DID `json:"other_did,omitempty"` + PKCEParams PKCEParams `json:"pkce_params"` + RedirectURI string `json:"redirect_uri,omitempty"` + Scope string `json:"scope,omitempty"` + SessionID string `json:"session_id,omitempty"` + TokenEndpoint string `json:"token_endpoint,omitempty"` + UseDPoP bool `json:"use_dpop,omitempty"` + // IssuerCredentialEndpoint: endpoint to exchange the access_token for a credential in the OpenID4VCI flow + IssuerCredentialEndpoint string `json:"issuer_credential_endpoint,omitempty"` +} + +// oauthClientFlow is used by a client to identify the flow a particular callback is part of +type oauthClientFlow = string + +const ( + // accessTokenRequestClientFlow is used in the standard authorization_code flow to request an access_token + accessTokenRequestClientFlow oauthClientFlow = "access_token_request" + // credentialRequestClientFlow is used in the OpenID4VCI Credential Request flow + credentialRequestClientFlow oauthClientFlow = "openid4vci_credential_request" +) // PEXConsumer consumes Presentation Submissions, according to https://identity.foundation/presentation-exchange/ // This is a component of a OpenID4VP Verifier. @@ -203,31 +215,3 @@ func (s OAuthSession) redirectURI() *url.URL { redirectURL, _ := url.Parse(s.RedirectURI) return redirectURL } - -// The Oid4vciSession is used to hold the state of an OIDC4VCi request between the moment -// the client application does the request, the OIDC4VCi flow and the redirect back to the -// client. The Oid4vciSession is referred to by a generated session id shared with the downstream -// OIDC4VCi issuer. -type Oid4vciSession struct { - // HolderDid: the DID of the wallet holder to who the VC will be issued to. - HolderDid *did.DID - // IssuerDid: the DID of the VC issuer, the party that will issue the VC to the holders' wallet - IssuerDid *did.DID - // RemoteRedirectUri: The redirect URL as provided by the external application requesting the issuance. - RemoteRedirectUri string - // RedirectUri: the URL send to the issuer as the redirect_uri of this nuts-node. - RedirectUri string - // PKCEParams: a set of Proof Key for Code Exchange parameters generated for this request. - PKCEParams PKCEParams - // IssuerTokenEndpoint: the endpoint for fetching the access token of the issuer. - IssuerTokenEndpoint string - // IssuerCredentialEndpoint: the endpoint for fetching the credential from the issuer with - // the access_token fetched from the IssuerTokenEndpoint. - IssuerCredentialEndpoint string -} - -// The remoteRedirectUri returns the RemoteRedirectUri as pared URL reference. -func (s Oid4vciSession) remoteRedirectUri() *url.URL { - redirectURL, _ := url.Parse(s.RemoteRedirectUri) - return redirectURL -} diff --git a/auth/api/iam/user.go b/auth/api/iam/user.go index 01df669acc..8be5f71ff9 100644 --- a/auth/api/iam/user.go +++ b/auth/api/iam/user.go @@ -133,13 +133,13 @@ func (r Wrapper) handleUserLanding(echoCtx echo.Context) error { // create oauthSession with userID from request // generate new sessionID and clientState with crypto.GenerateNonce() oauthSession := OAuthSession{ + ClientFlow: accessTokenRequestClientFlow, ClientState: crypto.GenerateNonce(), OwnDID: &redirectSession.OwnDID, PKCEParams: generatePKCEParams(), RedirectURI: accessTokenRequest.Body.RedirectUri, SessionID: redirectSession.SessionID, UseDPoP: useDPoP, - VerifierDID: verifier, TokenEndpoint: metadata.TokenEndpoint, } // store user session in session store under sessionID and clientState diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 95a40e45e8..4efb0f3cb1 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -233,7 +233,6 @@ type CredentialRequestProof struct { // CredentialResponse represents the response of a verifiable credential request. // It contains the Format and the actual Credential in JSON format. type CredentialResponse struct { - Format string `json:"format"` Credential string `json:"credential"` } diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 86c0dc4137..3d36d2e0b1 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -601,7 +601,6 @@ func TestIAMClient_VerifiableCredentials(t *testing.T) { require.NoError(t, err) require.NotNil(t, response) assert.Equal(t, "credential", response.Credential) - assert.Equal(t, "format", response.Format) }) t.Run("error - failed to get access token", func(t *testing.T) { ctx := createClientServerTestContext(t) diff --git a/docs/_static/auth/iam.partial.yaml b/docs/_static/auth/iam.partial.yaml index 92c6b36646..71d968dc40 100644 --- a/docs/_static/auth/iam.partial.yaml +++ b/docs/_static/auth/iam.partial.yaml @@ -208,6 +208,8 @@ paths: When the OAuth2 flow is completed, the user-agent is redirected to this endpoint. This can be the result of a successful authorization request or an error. The result of this callback is a redirect back to the calling application. + + This callback is used as the redirect_uri in multiple authorization request flows. operationId: callback tags: - oauth2 @@ -472,64 +474,6 @@ paths: "$ref": "#/components/schemas/VerifiableCredential" default: $ref: '../common/error_response.yaml' - /iam/oid4vci/callback: - get: - operationId: callbackOid4vciCredentialIssuance - summary: Callback for the Oid4VCI credential issuance flow. - description: | - The callback for the requestOid4vciCredentialIssuance request. - - This method will in most cases try to redirect (302) to the redirect_uri of the - requestOid4vciCredentialIssuance request with an error code. The only case - where the status will not be 302 is when something in the processing of the - redirect_uri itself goes wrong. - - 302 error returns: - * invalid_request - one of the provided parameters is wrong. - * server_error - internal processing of the Oid4VCI flow has a system error. - * access_denied - an access problem occurred with the internal processing of the Oid4VCI flow. - - If the system is somehow not able to return a redirect, the following HTTP status codes will be - returned: - * 500 - an system error occurred during processing - tags: - - auth - parameters: - - name: code - in: query - required: true - description: The oauth2 code response. - schema: - type: string - example: 55d7a35d-d7bf-436f-80f7-3fef4077f8a8 - - name: state - in: query - required: true - description: The oauth2 state, required as the authorize request sends it. - schema: - type: string - example: 55d7a35d-d7bf-436f-80f7-3fef4077f8a8 - - name: error - in: query - description: The error code. - schema: - type: string - - name: error_description - in: query - description: The error description. - schema: - type: string - responses: - '302': - description: | - The user-agent is redirected to the redirect_uri submitted at the request-credential request. - headers: - Location: - schema: - type: string - format: uri - default: - $ref: '../common/error_response.yaml' components: schemas: DIDDocument: diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index e9f62fbd6b..3e264e734c 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -208,7 +208,7 @@ paths: $ref: '../common/error_response.yaml' /internal/auth/v2/{did}/request-credential: post: - operationId: requestOid4vciCredentialIssuance + operationId: requestOpenid4VCICredentialIssuance summary: Start the Oid4VCI authorization flow. description: | Initiates an Oid4VCI flow to request an VC from a Credential Issuer. diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index aed8315687..083355078c 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -187,8 +187,8 @@ type Cnf struct { Jkt string `json:"jkt"` } -// RequestOid4vciCredentialIssuanceJSONBody defines parameters for RequestOid4vciCredentialIssuance. -type RequestOid4vciCredentialIssuanceJSONBody struct { +// RequestOpenid4VCICredentialIssuanceJSONBody defines parameters for RequestOpenid4VCICredentialIssuance. +type RequestOpenid4VCICredentialIssuanceJSONBody struct { AuthorizationDetails []map[string]interface{} `json:"authorization_details"` Issuer string `json:"issuer"` @@ -205,8 +205,8 @@ type ValidateDPoPProofJSONRequestBody = DPoPValidateRequest // CreateDPoPProofJSONRequestBody defines body for CreateDPoPProof for application/json ContentType. type CreateDPoPProofJSONRequestBody = DPoPRequest -// RequestOid4vciCredentialIssuanceJSONRequestBody defines body for RequestOid4vciCredentialIssuance for application/json ContentType. -type RequestOid4vciCredentialIssuanceJSONRequestBody RequestOid4vciCredentialIssuanceJSONBody +// RequestOpenid4VCICredentialIssuanceJSONRequestBody defines body for RequestOpenid4VCICredentialIssuance for application/json ContentType. +type RequestOpenid4VCICredentialIssuanceJSONRequestBody RequestOpenid4VCICredentialIssuanceJSONBody // RequestServiceAccessTokenJSONRequestBody defines body for RequestServiceAccessToken for application/json ContentType. type RequestServiceAccessTokenJSONRequestBody = ServiceAccessTokenRequest @@ -536,10 +536,10 @@ type ClientInterface interface { CreateDPoPProof(ctx context.Context, did string, body CreateDPoPProofJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // RequestOid4vciCredentialIssuanceWithBody request with any body - RequestOid4vciCredentialIssuanceWithBody(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + // RequestOpenid4VCICredentialIssuanceWithBody request with any body + RequestOpenid4VCICredentialIssuanceWithBody(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - RequestOid4vciCredentialIssuance(ctx context.Context, did string, body RequestOid4vciCredentialIssuanceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + RequestOpenid4VCICredentialIssuance(ctx context.Context, did string, body RequestOpenid4VCICredentialIssuanceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) // RequestServiceAccessTokenWithBody request with any body RequestServiceAccessTokenWithBody(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -636,8 +636,8 @@ func (c *Client) CreateDPoPProof(ctx context.Context, did string, body CreateDPo return c.Client.Do(req) } -func (c *Client) RequestOid4vciCredentialIssuanceWithBody(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewRequestOid4vciCredentialIssuanceRequestWithBody(c.Server, did, contentType, body) +func (c *Client) RequestOpenid4VCICredentialIssuanceWithBody(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRequestOpenid4VCICredentialIssuanceRequestWithBody(c.Server, did, contentType, body) if err != nil { return nil, err } @@ -648,8 +648,8 @@ func (c *Client) RequestOid4vciCredentialIssuanceWithBody(ctx context.Context, d return c.Client.Do(req) } -func (c *Client) RequestOid4vciCredentialIssuance(ctx context.Context, did string, body RequestOid4vciCredentialIssuanceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewRequestOid4vciCredentialIssuanceRequest(c.Server, did, body) +func (c *Client) RequestOpenid4VCICredentialIssuance(ctx context.Context, did string, body RequestOpenid4VCICredentialIssuanceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRequestOpenid4VCICredentialIssuanceRequest(c.Server, did, body) if err != nil { return nil, err } @@ -866,19 +866,19 @@ func NewCreateDPoPProofRequestWithBody(server string, did string, contentType st return req, nil } -// NewRequestOid4vciCredentialIssuanceRequest calls the generic RequestOid4vciCredentialIssuance builder with application/json body -func NewRequestOid4vciCredentialIssuanceRequest(server string, did string, body RequestOid4vciCredentialIssuanceJSONRequestBody) (*http.Request, error) { +// NewRequestOpenid4VCICredentialIssuanceRequest calls the generic RequestOpenid4VCICredentialIssuance builder with application/json body +func NewRequestOpenid4VCICredentialIssuanceRequest(server string, did string, body RequestOpenid4VCICredentialIssuanceJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader buf, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(buf) - return NewRequestOid4vciCredentialIssuanceRequestWithBody(server, did, "application/json", bodyReader) + return NewRequestOpenid4VCICredentialIssuanceRequestWithBody(server, did, "application/json", bodyReader) } -// NewRequestOid4vciCredentialIssuanceRequestWithBody generates requests for RequestOid4vciCredentialIssuance with any type of body -func NewRequestOid4vciCredentialIssuanceRequestWithBody(server string, did string, contentType string, body io.Reader) (*http.Request, error) { +// NewRequestOpenid4VCICredentialIssuanceRequestWithBody generates requests for RequestOpenid4VCICredentialIssuance with any type of body +func NewRequestOpenid4VCICredentialIssuanceRequestWithBody(server string, did string, contentType string, body io.Reader) (*http.Request, error) { var err error var pathParam0 string @@ -1059,10 +1059,10 @@ type ClientWithResponsesInterface interface { CreateDPoPProofWithResponse(ctx context.Context, did string, body CreateDPoPProofJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateDPoPProofResponse, error) - // RequestOid4vciCredentialIssuanceWithBodyWithResponse request with any body - RequestOid4vciCredentialIssuanceWithBodyWithResponse(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RequestOid4vciCredentialIssuanceResponse, error) + // RequestOpenid4VCICredentialIssuanceWithBodyWithResponse request with any body + RequestOpenid4VCICredentialIssuanceWithBodyWithResponse(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RequestOpenid4VCICredentialIssuanceResponse, error) - RequestOid4vciCredentialIssuanceWithResponse(ctx context.Context, did string, body RequestOid4vciCredentialIssuanceJSONRequestBody, reqEditors ...RequestEditorFn) (*RequestOid4vciCredentialIssuanceResponse, error) + RequestOpenid4VCICredentialIssuanceWithResponse(ctx context.Context, did string, body RequestOpenid4VCICredentialIssuanceJSONRequestBody, reqEditors ...RequestEditorFn) (*RequestOpenid4VCICredentialIssuanceResponse, error) // RequestServiceAccessTokenWithBodyWithResponse request with any body RequestServiceAccessTokenWithBodyWithResponse(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RequestServiceAccessTokenResponse, error) @@ -1183,7 +1183,7 @@ func (r CreateDPoPProofResponse) StatusCode() int { return 0 } -type RequestOid4vciCredentialIssuanceResponse struct { +type RequestOpenid4VCICredentialIssuanceResponse struct { Body []byte HTTPResponse *http.Response JSON200 *RedirectResponse @@ -1200,7 +1200,7 @@ type RequestOid4vciCredentialIssuanceResponse struct { } // Status returns HTTPResponse.Status -func (r RequestOid4vciCredentialIssuanceResponse) Status() string { +func (r RequestOpenid4VCICredentialIssuanceResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -1208,7 +1208,7 @@ func (r RequestOid4vciCredentialIssuanceResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r RequestOid4vciCredentialIssuanceResponse) StatusCode() int { +func (r RequestOpenid4VCICredentialIssuanceResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } @@ -1339,21 +1339,21 @@ func (c *ClientWithResponses) CreateDPoPProofWithResponse(ctx context.Context, d return ParseCreateDPoPProofResponse(rsp) } -// RequestOid4vciCredentialIssuanceWithBodyWithResponse request with arbitrary body returning *RequestOid4vciCredentialIssuanceResponse -func (c *ClientWithResponses) RequestOid4vciCredentialIssuanceWithBodyWithResponse(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RequestOid4vciCredentialIssuanceResponse, error) { - rsp, err := c.RequestOid4vciCredentialIssuanceWithBody(ctx, did, contentType, body, reqEditors...) +// RequestOpenid4VCICredentialIssuanceWithBodyWithResponse request with arbitrary body returning *RequestOpenid4VCICredentialIssuanceResponse +func (c *ClientWithResponses) RequestOpenid4VCICredentialIssuanceWithBodyWithResponse(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RequestOpenid4VCICredentialIssuanceResponse, error) { + rsp, err := c.RequestOpenid4VCICredentialIssuanceWithBody(ctx, did, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParseRequestOid4vciCredentialIssuanceResponse(rsp) + return ParseRequestOpenid4VCICredentialIssuanceResponse(rsp) } -func (c *ClientWithResponses) RequestOid4vciCredentialIssuanceWithResponse(ctx context.Context, did string, body RequestOid4vciCredentialIssuanceJSONRequestBody, reqEditors ...RequestEditorFn) (*RequestOid4vciCredentialIssuanceResponse, error) { - rsp, err := c.RequestOid4vciCredentialIssuance(ctx, did, body, reqEditors...) +func (c *ClientWithResponses) RequestOpenid4VCICredentialIssuanceWithResponse(ctx context.Context, did string, body RequestOpenid4VCICredentialIssuanceJSONRequestBody, reqEditors ...RequestEditorFn) (*RequestOpenid4VCICredentialIssuanceResponse, error) { + rsp, err := c.RequestOpenid4VCICredentialIssuance(ctx, did, body, reqEditors...) if err != nil { return nil, err } - return ParseRequestOid4vciCredentialIssuanceResponse(rsp) + return ParseRequestOpenid4VCICredentialIssuanceResponse(rsp) } // RequestServiceAccessTokenWithBodyWithResponse request with arbitrary body returning *RequestServiceAccessTokenResponse @@ -1526,15 +1526,15 @@ func ParseCreateDPoPProofResponse(rsp *http.Response) (*CreateDPoPProofResponse, return response, nil } -// ParseRequestOid4vciCredentialIssuanceResponse parses an HTTP response from a RequestOid4vciCredentialIssuanceWithResponse call -func ParseRequestOid4vciCredentialIssuanceResponse(rsp *http.Response) (*RequestOid4vciCredentialIssuanceResponse, error) { +// ParseRequestOpenid4VCICredentialIssuanceResponse parses an HTTP response from a RequestOpenid4VCICredentialIssuanceWithResponse call +func ParseRequestOpenid4VCICredentialIssuanceResponse(rsp *http.Response) (*RequestOpenid4VCICredentialIssuanceResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &RequestOid4vciCredentialIssuanceResponse{ + response := &RequestOpenid4VCICredentialIssuanceResponse{ Body: bodyBytes, HTTPResponse: rsp, }