yao/grpc/auth/guard.go
Max 7373b0b6f7 feat(tai): enhance gRPC tunnel functionality and internal host handling
- Introduced ExpandHosts function to parse and expand comma-separated host entries, including special values like "internal" and "localhost".
- Updated gRPC server to utilize the new ExpandHosts function for improved host management.
- Added HostHasInternal function to check for "internal" in host strings, enhancing configuration flexibility.
- Implemented new gRPC endpoints for TaiTunnel registration and forwarding, improving tunnel communication capabilities.
- Refactored authentication logic to include new TaiTunnel endpoints, ensuring proper access control.

Made-with: Cursor
2026-03-12 20:35:17 +08:00

171 lines
4.7 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"
taiRegisterMethod = "/tai.tunnel.TaiTunnel/Register"
taiForwardMethod = "/tai.tunnel.TaiTunnel/Forward"
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 and Tai tunnel (infrastructure services).
if fullMethod != apiMethod && fullMethod != taiRegisterMethod && fullMethod != taiForwardMethod {
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
}