// Package stpeter is the official Go client for st-peter (aura-users) — // the central authentication service. // // Design: authentication central, authorization local. st-peter answers // *who is this token?* — it returns the verified identity plus the user's // platform roles. What that identity may do inside a consuming service is // that service's own concern: keep a local roles table keyed by the // st-peter user id by value (no cross-DB FK) and map permissions there. // This package deliberately ships no session/permission types. package stpeter import ( "context" "errors" "fmt" "hash/fnv" "strings" "sync" "time" pb "git.awesomike.com/pub/st-peter-client/go/genpb/auth" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) // CookieName is the session cookie issued by aura-users on login. Shared // convention across the aura services so a session works on any of them. const CookieName = "aura_session" // authCacheTTL bounds how long a verified token is served from cache. A // revoked token stays "valid" for up to this long — logout flows must not // rely on the cache. const authCacheTTL = 60 * time.Second // ErrUnauthorized is returned when a token fails verification (or the // response carries no user). var ErrUnauthorized = errors.New("stpeter: unauthorized") type cacheEntry struct { user *pb.AuthenticatedUser at time.Time } // AuthClient is a thin client to aura-users' AuthService with a small // token-verify cache. It is safe for concurrent use; the underlying gRPC // channel is reference-counted. type AuthClient struct { conn *grpc.ClientConn svc pb.AuthServiceClient mu sync.RWMutex cache map[uint64]cacheEntry } // Connect dials aura-users and returns an AuthClient. The target is a gRPC // target (e.g. "127.0.0.1:9091" — no scheme). The dial is lazy: Connect // returns immediately and failures surface on the first call. func Connect(target string, opts ...grpc.DialOption) (*AuthClient, error) { defaults := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), } opts = append(defaults, opts...) conn, err := grpc.NewClient(target, opts...) if err != nil { return nil, fmt.Errorf("stpeter: dial %q: %w", target, err) } return &AuthClient{ conn: conn, svc: pb.NewAuthServiceClient(conn), cache: make(map[uint64]cacheEntry), }, nil } // Close tears down the underlying gRPC channel. func (c *AuthClient) Close() error { return c.conn.Close() } // VerifyToken verifies a session token, returning the authenticated user // with all of their roles. Verifications are cached ~60s to avoid a gRPC // round-trip per request. Returns ErrUnauthorized on a bad token. func (c *AuthClient) VerifyToken(ctx context.Context, token string) (*pb.AuthenticatedUser, error) { return c.VerifyTokenScoped(ctx, token, nil) } // VerifyTokenScoped verifies a session token, returning roles filtered to // the given scopes (e.g. []string{"cms"}) — each service pulls only its own // scope's roles. Nil/empty scopes = all roles. Cached ~60s per // (token, scopes) combination. func (c *AuthClient) VerifyTokenScoped(ctx context.Context, token string, roleScopes []string) (*pb.AuthenticatedUser, error) { key := cacheKey(token, roleScopes) c.mu.RLock() if e, ok := c.cache[key]; ok && time.Since(e.at) < authCacheTTL { c.mu.RUnlock() return e.user, nil } c.mu.RUnlock() resp, err := c.svc.VerifyToken(ctx, &pb.VerifyTokenRequest{ Token: token, IncludeUserRoles: true, RoleScopes: roleScopes, }) if err != nil { return nil, err } // Gate on Success first, then the user. The verified user is carried on // AuthenticationResponse.authenticated_user. if !resp.GetSuccess() || resp.GetAuthenticatedUser() == nil { return nil, ErrUnauthorized } user := resp.GetAuthenticatedUser() c.mu.Lock() c.cache[key] = cacheEntry{user: user, at: time.Now()} c.mu.Unlock() return user, nil } // LoginOutcome is the result of Login / VerifyTwoFactor. Exactly one of the // three fields is set. type LoginOutcome struct { // Authenticated carries the session (token + user + platform roles) on // success. A login bridge sets the CookieName cookie from its token. Authenticated *pb.AuthenticatedUser // TwoFactorID is non-empty when aura-users issued a one-time passcode // challenge; collect the code and call VerifyTwoFactor with it. TwoFactorID string // FailureMessage is non-empty when the login failed (bad credentials, // locked out, …) — a display message. FailureMessage string } // interpretAuthResponse maps an AuthenticationResponse to a LoginOutcome // (shared by Login and VerifyTwoFactor): 2FA-required first, then success, // else fail. func interpretAuthResponse(resp *pb.AuthenticationResponse) LoginOutcome { if resp.GetPassCodeRequired() { return LoginOutcome{TwoFactorID: resp.GetTwoFactorId()} } if resp.GetSuccess() && resp.GetAuthenticatedUser() != nil { return LoginOutcome{Authenticated: resp.GetAuthenticatedUser()} } msg := resp.GetMessage() if msg == "" { msg = "Invalid email or password." } return LoginOutcome{FailureMessage: msg} } // Login authenticates by identifier (email/phone) + password. func (c *AuthClient) Login(ctx context.Context, identifier, password string) (LoginOutcome, error) { resp, err := c.svc.Login(ctx, &pb.LoginRequest{ UserIdentifier: identifier, Password: password, }) if err != nil { return LoginOutcome{}, err } return interpretAuthResponse(resp), nil } // VerifyTwoFactor completes a two-factor challenge with the OTP code, // returning the authenticated session on success. func (c *AuthClient) VerifyTwoFactor(ctx context.Context, twoFactorID, code string) (LoginOutcome, error) { resp, err := c.svc.VerifyTwoFactor(ctx, &pb.VerifyTwoFactorRequest{ TwoFactorId: twoFactorID, Code: code, }) if err != nil { return LoginOutcome{}, err } return interpretAuthResponse(resp), nil } // LookupUser resolves a st-peter user by exact identifier (email / phone / // handle). LookupUser requires an authenticated actor — pass the acting // user's own user id + session token. Returns (nil, nil) when not found. func (c *AuthClient) LookupUser(ctx context.Context, actorUserID, actorToken, identifier string) (*pb.User, error) { resp, err := c.svc.LookupUser(ctx, &pb.LookupUserRequest{ UserId: actorUserID, UserToken: actorToken, Identifier: identifier, }) if err != nil { return nil, err } if !resp.GetSuccess() { return nil, nil } return resp.GetUser(), nil } // Bearer extracts a `Bearer ` from an Authorization header value. // The shared convention across aura services is: Bearer header first, then // the CookieName session cookie. func Bearer(authorization string) (string, bool) { tok, ok := strings.CutPrefix(authorization, "Bearer ") if !ok { return "", false } return strings.TrimSpace(tok), true } // cacheKey is a fast (non-cryptographic) hash of (token, scope filter), used // only as the cache key — the same token verified under different scope // filters returns different role sets and must not share an entry. func cacheKey(token string, roleScopes []string) uint64 { h := fnv.New64a() _, _ = h.Write([]byte(token)) for _, s := range roleScopes { _, _ = h.Write([]byte{0}) _, _ = h.Write([]byte(s)) } return h.Sum64() }