-
Notifications
You must be signed in to change notification settings - Fork 55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement OIDC4VCI Credential Endpoint #369
base: main
Are you sure you want to change the base?
Changes from all commits
6e64557
fcd2631
6bc45f9
c3bf59e
aa2c208
d52f4e2
4b5d2ff
92793ad
fd9c1b7
7859382
769a780
ba074b3
d068b07
8c0985f
3017fae
5c83bf4
77be08e
9f7ff48
6d49df5
02a247f
a771d0b
b6dd7dc
62f8f0b
0a2ef96
a50d773
af475fd
e87bcfc
c3d751f
498ee8b
30229af
bb9a671
dc3b3ee
b8f3a4c
e23773e
8ef452c
2332326
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -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 | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is no plug in libraries for this? |
||||||||||||||||
// Endpoint configured. A `nil` error represents an active token. | ||||||||||||||||
func (s introspecter) introspect(ctx context.Context, req *http.Request) error { | ||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||||||||||||||||
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") | ||||||||||||||||
} | ||||||||||||||||
Comment on lines
+59
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||
}(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) | ||||||||||||||||
} | ||||||||||||||||
} | ||||||||||||||||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -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 { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
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, | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can remove this and client secret? |
||||||
AuthStyle: oauth2.AuthStyleInHeader, | ||||||
} | ||||||
return conf | ||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am wondering if it may be confusing to co-locate server code for both binaries. what do you tink about having a duplicate file structure for oidc stuff? or, alliteratively a set of
oidc
packages within existing packages?