-
Notifications
You must be signed in to change notification settings - Fork 147
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
proposal(op): new server interface to replace storage #440
Comments
Hey @muhlemmer Great write up and proposal. I've checked out the branch and played around, but to your questions:
I think the easiest solution to go forward is to have a discussion today afternoon, as proposed by you. |
What's the branch name? |
|
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 Discussed: Because of the possibility of 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 Errors and status codes Currently we have the 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 Define an interface with a private method, which only package oidc
type IsDiscovery interface {
isDiscovery()
}
type DiscoveryConfiguration struct {
// ...
}
func (*DiscoveryConfiguration) isDiscovery() {} Use the interface a type in the 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 #440 (comment). |
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
oidc/pkg/op/token_code.go
Lines 64 to 103 in daf82a5
oidc/pkg/op/token_refresh.go
Lines 89 to 129 in daf82a5
Performance
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
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
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
oidc/pkg/op/server_legacy.go
Lines 9 to 81 in c340ed9
Planning
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
The text was updated successfully, but these errors were encountered: