244 lines
8.1 KiB
Go
244 lines
8.1 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
|
|
}
|
|
|
|
// VerifyApiKey verifies a service API key, returning the key's OWNING
|
|
// SERVICE USER plus that user's roles — consumers build their context
|
|
// identically to a session. Cached ~60s per key, like tokens.
|
|
func (c *AuthClient) VerifyApiKey(ctx context.Context, apiKey string) (*pb.AuthenticatedUser, error) {
|
|
key := cacheKey(apiKey, []string{"#api-key"}) // namespaced away from tokens
|
|
|
|
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.VerifyApiKey(ctx, &pb.VerifyApiKeyRequest{ApiKey: apiKey})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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()
|
|
}
|