Open muhlemmer opened 11 months ago
Hey @muhlemmer
Great write up and proposal.
I've checked out the branch and played around, but to your questions:
AuthorizeCallback
is not necessary anymore. It was only used because of the way the library worked with the Storage
interface. I guess we can just provide a helper function to be able to return the necessary response for Code Flow (code, state, ...), reps. Implicit Flow (tokens), where the latter is the same as used for the CodeExchange response.I think the easiest solution to go forward is to have a discussion today afternoon, as proposed by you.
What's the branch name?
What's the branch name?
next-server-interface
I've just pushed with some intermediate state / WIP on implementing the LegacyServer
Some notes after meeting with @livio-a:
VerifyClient
We shortly discussed if VerifyClient
should remain a method, because it is the only method that doesn't directly a handler. The alternative would be making it a utility function in which the implementation must pass the Client
.
Discussed: Because of the possibility of client_assertion
(JWT) the client ID might not be known until verification is complete. As such 2 utility functions would be required: To verify the client_assertion
and one to verify all other methods. Both will need to be called in each method that requires a Client verification.
The conclusion is that we will keep it as a method which can be implemented once.
Response object
Type enforcement in the response vs flexibility. With the current proposal, the return object is not enforced in any way. The Go type system also doen't provide ways to say "type that embeds another type". There could be some ways to hack something in using private interface methods. But is this the right way?
The goal must be that the interface is clear enough to implement. Yet, we want to offer flexibilty.
No definitive conclusion was made, but we lean to keep the any
type for flexibility. (I did an expiriment over the weekend which I will post shortly)
Errors and status codes
Currently we have the oidc.Error
type which in no way enforces a certain http Status Code. Most of the status codes are created in the AuthRequestError
and RequestError
functions. Storage errors are not properly evaluated and even if there was an error that had to do with the storage itself, we would mostly return a Bad Request instead of a Internal Server Error.
In the proposal branch there is now:
type StatusError struct {
parent error
statusCode int
}
Which would allow implementations to signal the proper signal code for an error case.
There is a concern that @livio-a raised that some standards specifically disallow certain status codes and that we should verify those standards before fully opening up StatusError capabilites.
Conclusion: check standards.
For the response object typing we could use a private method interface which then enforces users (mostly) to embed certain base types. I made a small example using the oidc.DiscoveryConfiguration
:
Define an interface with a private method, which only oidc
can implement:
package oidc
type IsDiscovery interface {
isDiscovery()
}
type DiscoveryConfiguration struct {
// ...
}
func (*DiscoveryConfiguration) isDiscovery() {}
Use the interface a type in the Response
object:
package op
type Server interface {
Discovery(context.Context, *Request[struct{}]) (*Response[oidc.IsDiscovery], error)
}
Implementation of an extended response:
package some_impl
type CustomDiscoveryConfig struct {
*oidc.DiscoveryConfiguration
Foo string `json:"foo"`
}
func (s *LegacyServer) Discovery(ctx context.Context, r *Request[none]) (*Response[oidc.IsDiscovery], error) {
return NewResponse[oidc.IsDiscovery](
&CustomDiscoveryConfig{
DiscoveryConfiguration: CreateDiscoveryConfig(ctx, s.provider, s.provider.Storage()),
Foo: "bar",
},
), nil
}
Also CC @lefelys , as he implemented the token exchange. As I figure now this also largely depends on custom storage. If you have time, we would love to hear you opinion on the proposed interface https://github.com/zitadel/oidc/issues/440#issue-1881779671.
It will be the scope of this framework to:
- ...
- Validate required fields in the request;
- Provide utility functions that helps implementations build the response;
The beautiful concept of current approach is abstraction of OIDC standards from user via simple Storage interface that must be implemented. It doesn't always work as expected, and very often internals must be reviewed to understand details (lack of documentation, good thing Zitadel is there as a reference) - but this is what everybody would expect from framework and improving this approach IMO is the right way to go.
In this proposal all OIDC internals are exposed to the implementer, which harms security and developer experience. From storage interface it is clear that I need to have auth request, access/refresh tokens, schema and methods to store them and retrieve. With Server interface it becomes unclear.
Maybe we can have a hybrid? Most of current storage methods can stay as they are (CRUD for core entities), and with utility functions there can be a complete Server implementation like this:
func (s Server) CodeExchange(ctx context.Context, r *ClientRequest[oidc.AccessTokenRequest]) (*Response, error) {
resp, err := utility.CodeExchange(ctx, r)
// mutate resp here if needed
return resp, err
}
This is a rough example. Like you mentioned if there is unimplemented server it can be used instead of utility as core implementation, and users can rewrite only methods they need to.
Better hooks and Interceptors also can be considered as an option.
This issue proposes a new interface
Server
in theop
package. It's purpose is to obsolete the current Storage interface and Provider type over time.Motivation
Community feedback
From the community (and at zitadel we feel the same pain):
Maintainability
In the current design there is a mix of business logic and the implementation of the OpenID connect / Oauth2 standards. Over time this resulted in overly complex code and duplication of code which is hard to understand and maintain.
Some examples
https://github.com/zitadel/oidc/blob/daf82a5e041cb1a2279d7e6f8f56d64dd58b4371/pkg/op/token_code.go#L64-L103 https://github.com/zitadel/oidc/blob/daf82a5e041cb1a2279d7e6f8f56d64dd58b4371/pkg/op/token_refresh.go#L89-L129Performance
Due to the business logic in OIDC and its reliance on the Storage interface. We found that there are multiple calls to certain methods. For example, we found for the token endpoint there are cases where
GetUserByID
is called 3 times. If we would sperate the business logic from implementation we could optimized this by a single call. Basically, business logic shouldn't be scope of the OIDC framework.Proposed interface
It will be the scope of this framework to:
It will be the scope of the implementer
Questions
There are still some doubts in the above design I would like to clarify:
AuthorizeCallback
is an endpoint which is not part of the standard, but required for Zitadel's login mechanism. Should it be part of this interface? (I believe it shouldn't, nut I'm open to other opinions)any
in the Response object, is that the right way to go? In my original concept I used the concrete types defined in theoidc
package, but figured that I would prevent flexibility if implementations need to return more. I've played with the idea of providing anExtra
map field for that purpose, but that would lead into messy code trying to merge and marshal the struct and map types. In the endjson.Marshal
takes an interface type anyway. On the flip side is that now it is not clear what response type is required from the method signature alone. One would always need to read the comments in the interface comments.Alternative response definitions
```go type Response[T any] struct { // Header map will be merged with the // header on the [http.ResponseWriter]. Header http.Header // Data will be JSON marshaled to // the response body. Data T Extra map[string]any } ... CodeExchange(context.Context, *ClientRequest[oidc.AccessTokenRequest]) (*Response[*oidc.AccessTokenResponse], error) ... ```Forward compatibility
What we've learned so far is that a central interface like
Storage
, needs to grows over time to suit different needs. We ended up with many optional interfaces and type assertions throughout the framework. Inspired much by how the gRPC ecosystem solves this,we want to provide andUnimplementedServer
implementation. TheUnimplementedServer
will implement all methods but will return an error describing that the particular endpoint isn't implemented on that server.Implementations will need to embed the
UnimplementedServer
in their struct type, so that we can keep adding methods without breaking those implementations. This is enforced by themustImpl()
un-exported method.How we match that with the
Endpoints
configuration and discovery still needs to be determined.Unimplemented server
```go type UnimplementedServer struct{} // UnimplementedStatusCode is the statuscode returned for methods // that are not yet implemented. // Note that this means methods in the sense of the Go interface, // and not http methods covered by "501 Not Implemented". var UnimplementedStatusCode = http.StatusNotFound func unimplementedError[T any](r *Request[T]) StatusError { err := oidc.ErrServerError().WithDescription(fmt.Sprintf("%s not implemented on this server", r.URL.Path)) return StatusError{ parent: err, statusCode: UnimplementedStatusCode, } } func (UnimplementedServer) mustImpl() {} func (UnimplementedServer) Health(_ context.Context, r *Request[struct{}]) (*Response, error) { return nil, unimplementedError(r) } func (UnimplementedServer) Ready(_ context.Context, r *Request[struct{}]) (*Response, error) { return nil, unimplementedError(r) } func (UnimplementedServer) Discovery(_ context.Context, r *Request[struct{}]) (*Response, error) { return nil, unimplementedError(r) } func (UnimplementedServer) Authorize(_ context.Context, r *Request[oidc.AuthRequest]) (*Redirect, error) { return nil, unimplementedError(r) } func (UnimplementedServer) DeviceAuthorization(_ context.Context, r *Request[oidc.DeviceAuthorizationRequest]) (*Response, error) { return nil, unimplementedError(r) } func (UnimplementedServer) VerifyClient(_ context.Context, r *Request[ClientCredentials]) (Client, error) { return nil, unimplementedError(r) } func (UnimplementedServer) CodeExchange(_ context.Context, r *ClientRequest[oidc.AccessTokenRequest]) (*Response, error) { return nil, unimplementedError(r.Request) } func (UnimplementedServer) RefreshToken(_ context.Context, r *ClientRequest[oidc.RefreshTokenRequest]) (*Response, error) { return nil, unimplementedError(r.Request) } func (UnimplementedServer) JWTProfile(_ context.Context, r *Request[oidc.JWTProfileGrantRequest]) (*Response, error) { return nil, unimplementedError(r) } func (UnimplementedServer) TokenExchange(_ context.Context, r *ClientRequest[oidc.TokenExchangeRequest]) (*Response, error) { return nil, unimplementedError(r.Request) } func (UnimplementedServer) ClientCredentialsExchange(_ context.Context, r *ClientRequest[oidc.ClientCredentialsRequest]) (*Response, error) { return nil, unimplementedError(r.Request) } func (UnimplementedServer) DeviceToken(_ context.Context, r *ClientRequest[oidc.DeviceAccessTokenRequest]) (*Response, error) { return nil, unimplementedError(r.Request) } func (UnimplementedServer) Introspect(_ context.Context, r *Request[oidc.IntrospectionRequest]) (*Response, error) { return nil, unimplementedError(r) } func (UnimplementedServer) UserInfo(_ context.Context, r *Request[oidc.UserInfoRequest]) (*Response, error) { return nil, unimplementedError(r) } func (UnimplementedServer) Revocation(_ context.Context, r *Request[oidc.RevocationRequest]) (*Response, error) { return nil, unimplementedError(r) } func (UnimplementedServer) EndSession(_ context.Context, r *Request[oidc.EndSessionRequest]) (*Response, error) { return nil, unimplementedError(r) } func (UnimplementedServer) Keys(_ context.Context, r *Request[struct{}]) (*Response, error) { return nil, unimplementedError(r) } ```Backward compatibility
When we will release the new
Server
interface, we wil also bring an implementation that uses the currentStorage
andProvider
underneath. That means implementations should be able to switch over without the direct need of refactoring their code base. There might be some small breaking changes in exported functions that are currently used inside the handlers, that's why we target v3.There is already a small proof of concept for Code Exchange and Refresh Token that ties the new
Server
to the oldProvider
here:Legacy Server
https://github.com/zitadel/oidc/blob/c340ed9ed587f8dd7b341acff464e3f92cf104a1/pkg/op/server_legacy.go#L9-L81Planning
We intend to implement an experimental version of this interface into the V3 release. Current users of the
Provider
andStorage
interfaces will not directly be affected by the addition and have a choice to use the oldProvider
,Provider
wrapped in aLegacyServer
or start implementing from scratch. Following inclusion, we will have to start re-writing most functions that thatStorage
arguments to accept valued arguments instead. Once that is completed we will be able to start deprecating theProvider
andStorage
types.General Roadmap:
Server
interface, state experimental.LegacyServer
implementation.Provider
.Server
interfaceStorage
calls in exported functions, use values instead.Storage
.After
Storage
has been deprecated, we will stop accepting new features for it. Actual removal will depend on community needs.CC: @muir @livio-a @adlerhurst