- Add gRPC server configuration to the application, allowing for gRPC communication. - Introduce new Makefile targets for gRPC unit testing and proto code generation. - Update CI workflows to include gRPC tests with SQLite as the transport layer. - Refactor the sandbox design to support multi-node capabilities and improve isolation. - Enhance the service layer to facilitate internal request forwarding for gRPC APIs. This commit lays the groundwork for integrating gRPC into the Yao SDK, improving performance and scalability.
169 lines
4.5 KiB
Go
169 lines
4.5 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/metadata"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"github.com/yaoapp/yao/openapi/oauth"
|
|
"github.com/yaoapp/yao/openapi/oauth/acl"
|
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
|
)
|
|
|
|
const (
|
|
healthzMethod = "/yao.Yao/Healthz"
|
|
apiMethod = "/yao.Yao/API"
|
|
|
|
metaAuthorization = "authorization"
|
|
metaRefreshToken = "x-refresh-token"
|
|
metaAccessToken = "x-access-token"
|
|
metaSandboxID = "x-sandbox-id"
|
|
metaSessionID = "x-session-id"
|
|
)
|
|
|
|
type authCtxKey struct{}
|
|
|
|
// WithAuthorizedInfo stores AuthorizedInfo in context for downstream handlers.
|
|
func WithAuthorizedInfo(ctx context.Context, info *types.AuthorizedInfo) context.Context {
|
|
return context.WithValue(ctx, authCtxKey{}, info)
|
|
}
|
|
|
|
// GetAuthorizedInfo retrieves AuthorizedInfo from context (set by the interceptor).
|
|
func GetAuthorizedInfo(ctx context.Context) *types.AuthorizedInfo {
|
|
info, _ := ctx.Value(authCtxKey{}).(*types.AuthorizedInfo)
|
|
return info
|
|
}
|
|
|
|
// UnaryInterceptor is the gRPC unary server interceptor for authentication and authorization.
|
|
func UnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
|
if info.FullMethod == healthzMethod {
|
|
return handler(ctx, req)
|
|
}
|
|
|
|
ctx, err := authenticate(ctx, info.FullMethod, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return handler(ctx, req)
|
|
}
|
|
|
|
// StreamInterceptor is the gRPC stream server interceptor for authentication and authorization.
|
|
// For streaming RPCs, the request object is not available at intercept time,
|
|
// so ACL scope check uses the method-level virtual path (without request-specific IDs).
|
|
func StreamInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
|
if info.FullMethod == healthzMethod {
|
|
return handler(srv, ss)
|
|
}
|
|
|
|
ctx, err := authenticate(ss.Context(), info.FullMethod, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return handler(srv, &wrappedStream{ServerStream: ss, ctx: ctx})
|
|
}
|
|
|
|
// authenticate calls oauth.Service.AuthenticateToken directly — no gin/HTTP shim.
|
|
func authenticate(ctx context.Context, fullMethod string, req interface{}) (context.Context, error) {
|
|
md, ok := metadata.FromIncomingContext(ctx)
|
|
if !ok {
|
|
return ctx, status.Error(codes.Unauthenticated, "missing metadata")
|
|
}
|
|
|
|
svc := oauth.OAuth
|
|
if svc == nil {
|
|
return ctx, status.Error(codes.Internal, "oauth service not initialized")
|
|
}
|
|
|
|
bearer := extractBearer(md)
|
|
if bearer == "" {
|
|
return ctx, status.Error(codes.Unauthenticated, "missing authorization token")
|
|
}
|
|
|
|
result, err := svc.AuthenticateToken(oauth.AuthInput{
|
|
AccessToken: bearer,
|
|
RefreshToken: extractMeta(md, metaRefreshToken),
|
|
SessionID: extractMeta(md, metaSessionID),
|
|
})
|
|
if err != nil {
|
|
return ctx, status.Error(codes.Unauthenticated, err.Error())
|
|
}
|
|
|
|
ctx = WithAuthorizedInfo(ctx, result.Info)
|
|
|
|
if result.NewAccessToken != "" {
|
|
_ = grpc.SendHeader(ctx, metadata.Pairs(
|
|
metaAccessToken, result.NewAccessToken,
|
|
metaRefreshToken, result.NewRefreshToken,
|
|
))
|
|
}
|
|
|
|
// ACL scope check — skip for API proxy (the openapi router does its own auth).
|
|
if fullMethod != apiMethod {
|
|
httpMethod, httpPath := VirtualEndpoint(fullMethod, req)
|
|
scopes := strings.Fields(result.Info.Scope)
|
|
|
|
enforcer := getACLEnforcer()
|
|
if enforcer != nil && enforcer.Scope != nil {
|
|
decision := enforcer.Scope.Check(&acl.AccessRequest{
|
|
Method: httpMethod,
|
|
Path: httpPath,
|
|
Scopes: scopes,
|
|
})
|
|
if !decision.Allowed {
|
|
return ctx, status.Errorf(codes.PermissionDenied, "insufficient scope: %s", decision.Reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
return ctx, nil
|
|
}
|
|
|
|
// getACLEnforcer returns the ACL enforcer if available and enabled.
|
|
func getACLEnforcer() *acl.ACL {
|
|
if acl.Global == nil {
|
|
return nil
|
|
}
|
|
enforcer, ok := acl.Global.(*acl.ACL)
|
|
if !ok || enforcer == nil {
|
|
return nil
|
|
}
|
|
if !enforcer.Config.Enabled {
|
|
return nil
|
|
}
|
|
return enforcer
|
|
}
|
|
|
|
func extractBearer(md metadata.MD) string {
|
|
vals := md.Get(metaAuthorization)
|
|
if len(vals) == 0 {
|
|
return ""
|
|
}
|
|
parts := strings.SplitN(vals[0], " ", 2)
|
|
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
|
|
return parts[1]
|
|
}
|
|
return vals[0]
|
|
}
|
|
|
|
func extractMeta(md metadata.MD, key string) string {
|
|
vals := md.Get(key)
|
|
if len(vals) == 0 {
|
|
return ""
|
|
}
|
|
return vals[0]
|
|
}
|
|
|
|
// wrappedStream wraps grpc.ServerStream with a custom context.
|
|
type wrappedStream struct {
|
|
grpc.ServerStream
|
|
ctx context.Context
|
|
}
|
|
|
|
func (w *wrappedStream) Context() context.Context {
|
|
return w.ctx
|
|
}
|