st-peter-client/go/auth.go

216 lines
7.2 KiB
Go

// 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 <token>` 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()
}