- 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
171 lines
4.7 KiB
Go
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
|
|
}
|