first commit

Polyglot client for st-peter (aura-users) — Rust / Go / TypeScript, one
vendored auth proto, versioned in lockstep with the st-peter server (v0.2.0).
Mirrors waymaker-client's layout: proto/ + scripts/ + per-language packages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michael Netshipise 2026-06-10 14:45:00 +02:00
commit dae39e984f
26 changed files with 20593 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/target
Cargo.lock
ts/node_modules/
ts/dist/
.DS_Store

6
Cargo.toml Normal file
View File

@ -0,0 +1,6 @@
# Root workspace so the Rust crate in rust/ is discoverable when this repo is
# consumed as a Cargo git dependency, and so `cargo` works from the repo root.
# The go/ and ts/ clients are independent toolchains and ignored by Cargo.
[workspace]
resolver = "2"
members = ["rust"]

88
README.md Normal file
View File

@ -0,0 +1,88 @@
# st-peter-client
Official client libraries for [st-peter](https://git.awesomike.com/dev/st-peter-lib)
(deployed as **aura-users**) — the central authentication service — over gRPC.
Three clients, one wire contract, **versioned in lockstep with the st-peter
server**:
| Language | Path | Package |
|------------|---------|---------|
| Rust | `rust/` | `st-peter-client` (crate) |
| Go | `go/` | `git.awesomike.com/pub/st-peter-client/go` (module) |
| TypeScript | `ts/` | `@st-peter/client` (npm) |
The `.proto` file in `proto/` is a **vendored copy**; the st-peter server repo
is the source of truth. `scripts/sync-protos.sh` refreshes it and keeps
`VERSION` aligned with the server. Only the **auth** surface is vendored —
services authenticate users; they do not administer st-peter.
## Versioning
Every release is tagged at the **same version as the st-peter server** it
targets (see `VERSION`). A client tagged `v0.2.0` speaks the wire contract of
st-peter `v0.2.0`. The gRPC wire format is backward-compatible across patch
releases (field numbers and enum integer values are stable), so a client one
patch behind a server generally interoperates — but match versions for new
surface.
## Design: authentication central, authorization local
st-peter answers *who is this token?* — every client returns the verified
identity plus the user's **platform** roles, with a ~60s token-verify cache.
What that identity may do inside a consuming service (media roles, CMS
roles, …) 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. These clients deliberately ship no session/permission types.
The wrapper surface is the same in all three languages:
- `connect(target)` — lazy dial; failures surface on the first call
- `verify_token(token)` — verified user + platform roles, cached ~60s
- `login(identifier, password)``Authenticated | TwoFactor | Failed`
- `verify_two_factor(two_factor_id, code)` — complete an OTP challenge
- `lookup_user(actor_id, actor_token, identifier)` — resolve a user by
email/phone/handle (for local role-grant flows)
- `bearer(...)` helper + the shared `aura_session` cookie name
## Layout
```
proto/ vendored st-peter-auth.proto (source of truth: st-peter repo)
rust/ cargo package; stubs compiled at build time from ../proto
go/ go module; committed stubs in genpb/ (scripts/gen-go.sh)
ts/ npm package; committed stubs in src/genpb (scripts/gen-ts.sh)
scripts/ sync-protos.sh, gen-go.sh, gen-ts.sh
VERSION the st-peter server version this client targets
```
## Regenerating after a proto change
```bash
ST_PETER_REPO=/path/to/st-peter-lib ./scripts/sync-protos.sh
./scripts/gen-go.sh # needs protoc-gen-go{,-grpc}
(cd ts && npm install) && ./scripts/gen-ts.sh
cargo check # rust stubs regenerate via build.rs
```
Then tag at `v$(cat VERSION)`.
## Consuming
Rust (`Cargo.toml`):
```toml
st-peter-client = { git = "https://git.awesomike.com/pub/st-peter-client.git", tag = "v0.2.0" }
```
Go:
```bash
go get git.awesomike.com/pub/st-peter-client/go@v0.2.0
```
TypeScript (`package.json`):
```json
"@st-peter/client": "git+https://git.awesomike.com/pub/st-peter-client.git#v0.2.0"
```

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.2.0

13
go/README.md Normal file
View File

@ -0,0 +1,13 @@
# st-peter-client (Go)
Official Go client for st-peter (aura-users). Generated stubs are committed
under `genpb/` (regenerate with `../scripts/gen-go.sh`); the `stpeter.AuthClient`
wrapper (token-verify cache, login/2FA/lookup) is layered on top.
```go
auth, err := stpeter.Connect("127.0.0.1:9091")
user, err := auth.VerifyToken(ctx, token) // cached ~60s
```
See the repo root README for versioning and the
authentication-central / authorization-local design.

201
go/auth.go Normal file
View File

@ -0,0 +1,201 @@
// 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
// (+ platform 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) {
key := tokenHash(token)
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, // platform roles; service roles stay local
})
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
}
// tokenHash is a fast (non-cryptographic) hash of the token, used only as
// the cache key.
func tokenHash(token string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(token))
return h.Sum64()
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

15
go/go.mod Normal file
View File

@ -0,0 +1,15 @@
module git.awesomike.com/pub/st-peter-client/go
go 1.26.2
require (
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
)
require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
)

38
go/go.sum Normal file
View File

@ -0,0 +1,38 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

633
proto/st-peter-auth.proto Normal file
View File

@ -0,0 +1,633 @@
syntax = "proto3";
package st_peter.auth;
option go_package = "nandie.com/pkg/;auth_service";
import "google/protobuf/timestamp.proto";
service AuthService {
rpc RegisterUser (RegisterUserRequest) returns (RegisterUserResponse);
rpc VerifyRegisterUser (VerifyRegisterUserRequest) returns (AuthenticationResponse);
rpc Login (LoginRequest) returns (AuthenticationResponse);
rpc Logout (LogoutRequest) returns (LogoutResponse);
rpc VerifyToken (VerifyTokenRequest) returns (AuthenticationResponse);
rpc VerifyAuthClaimToken (VerifyAuthClaimTokenRequest) returns (AuthenticationResponse);
rpc InitiateTwoFactor (InitiateTwoFactorRequest) returns (InitiateTwoFactorResponse);
rpc VerifyTwoFactor (VerifyTwoFactorRequest) returns (AuthenticationResponse);
rpc InitiatePasswordReset (InitiatePasswordResetRequest) returns (PasswordResetTokenResponse);
rpc VerifyPasswordResetToken (VerifyPasswordResetTokenRequest) returns (PasswordResetTokenResponse);
rpc ResetPassword (ResetPasswordRequest) returns (AuthenticationResponse);
rpc ResendVerificationCode (ResendVerificationRequest) returns (ResendVerificationResponse);
rpc UpdateUserInfo (UpdateUserInfoRequest) returns (UpdateUserInfoResponse);
rpc ChangeIdentityField (ChangeIdentityFieldRequest) returns (ChangeIdentityFieldResponse);
rpc VerifyIdentityField (VerifyIdentityFieldRequest) returns (VerifyIdentityFieldResponse);
rpc ResendIdentifierChangeCode(ResendIdentityFieldRequest) returns (ChangeIdentityFieldResponse);
rpc UpdateUserPreference (UpdateUserPreferenceRequest) returns (OperationResponse);
rpc GetUserPreferenceByCode (GetUserPreferenceByCodeRequest) returns (GetUserPreferenceByCodeResponse);
rpc GetUserSessions(GetUserSessionsRequest) returns (GetUserSessionsResponse);
rpc ClearUserSessions(ClearUserSessionsRequest) returns (ClearUserSessionsResponse);
// Social login - Mobile flow (validates ID token from native SDK)
rpc SocialLogin(SocialLoginRequest) returns (SocialLoginResponse);
// Social login - Web OAuth flow
rpc InitiateOAuth(InitiateOAuthRequest) returns (InitiateOAuthResponse);
rpc CompleteOAuth(OAuthCallbackRequest) returns (SocialLoginResponse);
// Account linking
rpc LinkSocialAccount(LinkSocialAccountRequest) returns (OperationResponse);
rpc UnlinkSocialAccount(UnlinkSocialAccountRequest) returns (OperationResponse);
rpc GetLinkedAccounts(GetLinkedAccountsRequest) returns (GetLinkedAccountsResponse);
// API Keys
rpc CreateApiKey(CreateApiKeyRequest) returns (CreateApiKeyResponse);
rpc ListApiKeys(ListApiKeysRequest) returns (ListApiKeysResponse);
rpc RevokeApiKey(RevokeApiKeyRequest) returns (OperationResponse);
rpc VerifyApiKey(VerifyApiKeyRequest) returns (AuthenticationResponse);
// Password policy (public, no auth required)
rpc GetPasswordPolicy(GetPasswordPolicyRequest) returns (GetPasswordPolicyResponse);
// Metrics
rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse);
// User lookup by identifier (email, phone, or handle) any authenticated user
rpc LookupUser(LookupUserRequest) returns (LookupUserResponse);
}
message Date {
int32 year = 1;
uint32 month = 2;
uint32 day = 3;
}
enum ResultCode {
RESULT_CODE_SUCCESS = 0;
RESULT_CODE_NOT_FOUND = 1;
RESULT_CODE_INTERNAL_SERVER_ERROR = 2;
RESULT_CODE_BAD_REQUEST = 3;
RESULT_CODE_NOT_AUTHORIZED = 4;
RESULT_CODE_FORBIDDEN = 5;
RESULT_CODE_VALIDATION_ERRORS = 6;
RESULT_CODE_PASSCODE_REQUIRED = 7;
RESULT_CODE_TOO_MANY_REQUESTS = 9;
RESULT_CODE_INVALID_CREDENTIALS = 8;
RESULT_CODE_INACTIVE_USER = 10;
RESULT_CODE_IDENTITY_IN_USE = 11;
RESULT_CODE_NEXT_STEP = 12;
}
enum ChannelType {
CHANNEL_TYPE_UNSPECIFIED = 0;
CHANNEL_TYPE_EMAIL = 1;
CHANNEL_TYPE_SMS = 2;
}
enum VerificationCodeType {
VERIFICATION_CODE_TYPE_UNSPECIFIED = 0;
VERIFICATION_CODE_TYPE_REGISTER = 1;
VERIFICATION_CODE_TYPE_PASSWORD_RESET = 2;
VERIFICATION_CODE_TYPE_OTP_LOGIN = 3;
}
message User {
string id = 1;
string email = 2;
string phone = 3;
string first_names = 4;
string last_name = 5;
string profile_picture_url = 6;
optional string handle = 7;
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
google.protobuf.Timestamp deleted_at = 12;
google.protobuf.Timestamp last_login = 13;
bool is_active = 20;
bool is_email_verified = 21;
bool is_phone_verified = 22;
Date date_of_birth = 23;
int64 version = 24;
repeated SocialAccount social_accounts = 30;
}
message UserPreference {
string id = 1;
string user_id = 2;
string preference_code = 3;
string preference_value_type = 4;
string preference_value = 5;
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
}
message Role {
string id = 1;
string code = 2;
string description = 3;
google.protobuf.Timestamp created_at = 4;
google.protobuf.Timestamp updated_at = 5;
}
message SocialAccount {
string provider = 1;
string provider_user_id = 2;
string access_token = 3;
google.protobuf.Timestamp expires_at = 4;
string email = 5;
string name = 6;
string profile_picture_url = 7;
bool email_verified = 8;
}
// OAuth Provider enumeration
enum OAuthProvider {
OAUTH_PROVIDER_UNSPECIFIED = 0;
OAUTH_PROVIDER_GOOGLE = 1;
OAUTH_PROVIDER_APPLE = 2;
OAUTH_PROVIDER_FACEBOOK = 3;
OAUTH_PROVIDER_MICROSOFT = 4;
}
// Mobile flow - validate ID token from native SDK
message SocialLoginRequest {
OAuthProvider provider = 1;
string id_token = 2; // JWT from Google/Apple SDK
string access_token = 3; // For Facebook
DeviceInfo device_info = 4;
string nonce = 5; // For Apple Sign-In security
}
message SocialLoginResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
AuthenticatedUser authenticated_user = 4;
bool is_new_user = 5;
bool was_auto_linked = 6;
repeated ValidationError validation_errors = 7;
}
// Web flow - initiate OAuth redirect
message InitiateOAuthRequest {
OAuthProvider provider = 1;
string redirect_uri = 2;
string state = 3; // Client-provided state for additional verification
}
message InitiateOAuthResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
string authorization_url = 4;
string state = 5; // Server-generated state token
}
// Web flow - handle callback
message OAuthCallbackRequest {
OAuthProvider provider = 1;
string code = 2;
string state = 3;
DeviceInfo device_info = 4;
}
// Account linking
message LinkSocialAccountRequest {
string actor_id = 1;
string actor_token = 2;
OAuthProvider provider = 3;
string id_token = 4;
string access_token = 5;
string nonce = 6; // For Apple Sign-In
}
message UnlinkSocialAccountRequest {
string actor_id = 1;
string actor_token = 2;
OAuthProvider provider = 3;
}
message GetLinkedAccountsRequest {
string actor_id = 1;
string actor_token = 2;
}
message GetLinkedAccountsResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
repeated SocialAccount accounts = 4;
}
message RegisterUserRequest {
string user_identifier = 1;
string password = 2;
string first_names = 4;
string last_name = 5;
string app_hash = 6;
DeviceInfo device_info = 7;
}
message RegisterUserResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
string registration_id = 4;
repeated ValidationError validation_errors = 5;
}
message VerifyRegisterUserRequest {
string user_identifier = 1; // Can be either email or phone
string registration_id = 2;
string token = 3; //Token has a redirect to encoded in it.
DeviceInfo device_info = 4;
}
message UserResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
User user = 4;
}
message DeviceInfo {
string application_name = 1; // e.g., 'web', 'mobile'
string application_version = 2; //e.g., '1.0.0'
string device_name = 3; // e.g., 'iPhone X', 'Pixel 2'
string device_type = 4; // -- e.g., 'desktop', 'mobile'
string device_os = 5; // e. g., 'iOS', 'Android', 'Windows'
string device_os_version = 6; // e.g., '10', '11.4.1'
string device_id = 7; // e.g., 'imei:1234567890', 'serial:1234567890'
}
message LoginRequest {
string user_identifier = 1; // Can be either email or phone
string password = 2;
string app_hash = 3;
DeviceInfo device_info = 4;
}
message AuthenticationResponse {
bool success = 1;
bool pass_code_required = 2;
string two_factor_id = 3;
ResultCode result_code = 8;
string message = 9;
AuthenticatedUser authenticated_user = 10;
}
message AuthenticatedUser {
User user = 1;
string token = 2;
string session_id = 3;
google.protobuf.Timestamp expires_at = 4;
repeated UserPreference user_preferences = 5;
repeated AssignedUserRole user_roles = 11;
}
message AssignedUserRole {
string id = 1;
string user_id = 2;
string role_id = 3;
string role_name = 4;
string scope_code = 5;
string target_id = 6;
}
message VerifyTokenRequest {
string token = 1;
bool include_user_roles = 2;
repeated string role_scopes = 3;
repeated string role_names = 4;
}
message VerifyAuthClaimTokenRequest {
string claim = 2;
}
message InitiateTwoFactorResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
string two_factor_id = 4;
ChannelType channel = 5;
string user_id = 7;
repeated ValidationError validation_errors = 6;
}
message VerifyTwoFactorResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
repeated ValidationError validation_errors = 4;
}
message InitiateTwoFactorRequest {
optional string user_id = 1;
optional string user_identifier = 5; // Can be either email or phone
ChannelType channel = 2;
string app_hash = 3;
DeviceInfo device_info = 4;
}
message VerifyTwoFactorRequest {
string two_factor_id = 1;
string code = 2;
DeviceInfo device_info = 3;
}
message InitiatePasswordResetRequest {
string user_identifier = 1; // Can be either email or phone
string app_hash = 2;
DeviceInfo device_info = 4;
string new_password = 5;
}
message OperationResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
}
message VerifyPasswordResetTokenRequest {
string user_id = 1;
string password_reset_id = 2;
string passcode = 3;
}
message PasswordResetTokenResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
string password_reset_id = 4;
}
message ResetPasswordRequest {
string user_id = 1;
string password_reset_id = 2;
string passcode = 3;
DeviceInfo device_info = 4;
}
enum IdentityField {
IDENTITY_FIELD_UNSPECIFIED = 0;
IDENTITY_FIELD_EMAIL = 1;
IDENTITY_FIELD_phone = 2;
IDENTITY_FIELD_PASSWORD = 3;
IDENTITY_FIELD_HANDLE = 4;
}
message ChangeIdentityFieldRequest {
string user_id = 1;
string user_token = 2;
ChannelType channel = 3;
string new_value = 4;
IdentityField field_type = 5;
string app_hash = 6;
DeviceInfo device_info = 7;
}
message ChangeIdentityFieldResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
string challenge_id = 4;
int32 passcode_size = 5;
string user_identifier = 6;
ChannelType channel = 7;
repeated ValidationError validation_errors = 8;
}
message VerifyIdentityFieldRequest {
string user_id = 1;
string user_token = 2;
string challenge_id = 4;
string verification_code = 5;
string app_hash = 6;
DeviceInfo device_info = 7;
}
message ResendIdentityFieldRequest {
string user_id = 1;
string user_token = 2;
string challenge_id = 4;
string app_hash = 6;
DeviceInfo device_info = 7;
}
message VerifyIdentityFieldResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
bool requires_verification = 8;
string challenge_id = 4;
int32 passcode_size = 5;
string user_identifier = 6;
ChannelType channel = 7;
repeated ValidationError validation_errors = 9;
}
message UpdateUserInfoRequest {
string user_id = 1;
string user_token = 2;
optional string first_names = 3;
optional string last_name = 4;
optional string profile_picture_id = 6;
Date date_of_birth = 5;
optional string handle = 7; // Optional unique handle (e.g., @username)
}
message UpdateUserInfoResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
User user = 4;
}
message LogoutRequest {
string token = 1;
}
message LogoutResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
}
message ValidationError {
string field = 1;
string message = 2;
}
message Scope {
string code = 1;
string description = 2;
optional string parent_code = 3;
bool is_active = 4;
}
message UpdateUserPreferenceRequest {
string actor_id = 1;
string actor_token = 2;
string preference_key = 3;
string preference_value = 4;
}
message GetUserPreferenceByCodeRequest {
string actor_id = 1;
string actor_token = 2;
string preference_code = 3;
}
message GetUserPreferenceByCodeResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
string value = 4;
}
message ResendVerificationRequest {
string user_identifier = 1;
VerificationCodeType verification_code_type = 2;
string app_hash = 3;
string verification_code_id = 4;
DeviceInfo device_info = 19;
}
message ResendVerificationResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
string verification_code_id = 4;
}
message UserSession {
string id = 1;
string user_id = 2;
DeviceInfo device_info = 3;
google.protobuf.Timestamp created_at = 4;
google.protobuf.Timestamp expires_at = 5;
google.protobuf.Timestamp last_activity = 6;
bool is_active = 7;
string ip_address = 8;
string user_agent = 9;
}
message GetUserSessionsRequest {
string actor_id = 1;
string actor_token = 2;
int32 page = 3;
int32 size = 4;
}
message GetUserSessionsResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
repeated UserSession sessions = 4;
int32 total = 5;
}
message ClearUserSessionsRequest {
string actor_id = 1;
string actor_token = 2;
repeated string session_ids = 3; // If empty, clears all sessions except current
}
message ClearUserSessionsResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
int32 cleared_count = 4;
}
// Metrics
message GetMetricsRequest {
string bearer_token = 1;
}
message GetMetricsResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
string metrics = 4; // Prometheus text format
}
// API Key messages
message CreateApiKeyRequest {
string token = 1; // existing session token for auth
string name = 2;
repeated string scopes = 3;
int64 expires_at = 4; // 0 = never
}
message CreateApiKeyResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
string api_key = 4; // full key, shown ONCE
string key_id = 5;
string key_prefix = 6;
}
message ListApiKeysRequest {
string token = 1;
}
message ListApiKeysResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
repeated ApiKeyInfo keys = 4;
}
message ApiKeyInfo {
string id = 1;
string name = 2;
string key_prefix = 3;
repeated string scopes = 4;
int64 last_used_at = 5;
int64 expires_at = 6;
int64 created_at = 7;
bool is_active = 8;
}
message RevokeApiKeyRequest {
string token = 1;
string key_id = 2;
}
message VerifyApiKeyRequest {
string api_key = 1;
}
// Password policy
message GetPasswordPolicyRequest {}
message GetPasswordPolicyResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
uint32 min_length = 4;
bool requires_uppercase = 5;
bool requires_special_character = 6;
}
// Lookup user by exact identifier (email, phone number, or handle)
message LookupUserRequest {
string user_id = 1;
string user_token = 2;
string identifier = 3; // email, phone number, or handle
}
message LookupUserResponse {
bool success = 1;
ResultCode result_code = 2;
string message = 3;
optional User user = 4;
}

30
rust/Cargo.toml Normal file
View File

@ -0,0 +1,30 @@
[package]
name = "st-peter-client"
version = "0.2.0"
edition = "2021"
description = "Official Rust client for st-peter (aura-users) — authentication over gRPC with a token-verify cache"
repository = "https://git.awesomike.com/pub/st-peter-client"
license = "MIT OR Apache-2.0"
[lib]
name = "st_peter_client"
path = "src/lib.rs"
[dependencies]
# Async runtime. `sync` for the token-verify cache RwLock; nothing else.
tokio = { version = "1", default-features = false, features = ["sync", "time"] }
thiserror = "2"
# gRPC client. tls-ring lets the client talk to a TLS-protected aura-users
# without dragging in any server-side TLS termination code.
tonic = { version = "0.14", features = ["codegen", "tls-ring"] }
tonic-prost = "0.14"
prost = "0.14"
prost-types = "0.14"
# `bearer()` header extraction (the same `http` major tonic uses).
http = "1"
[build-dependencies]
# Compiles the vendored proto in ../proto into client stubs at build time.
tonic-prost-build = "0.14"

14
rust/README.md Normal file
View File

@ -0,0 +1,14 @@
# st-peter-client (Rust)
Official Rust client for st-peter (aura-users). Stubs are generated at build
time from `../proto/st-peter-auth.proto`; the ergonomic [`AuthClient`] wrapper
(token-verify cache, login/2FA/lookup) is layered on top, with the raw wire
surface available under `st_peter_client::authpb`.
```rust
let auth = st_peter_client::AuthClient::connect("http://127.0.0.1:9091").await?;
let user = auth.verify_token(&token).await?; // cached ~60s
```
See the repo root README for versioning and the
authentication-central / authorization-local design.

12
rust/build.rs Normal file
View File

@ -0,0 +1,12 @@
// Compile the vendored proto (../proto, synced from the st-peter server repo)
// into client stubs. Client-only: no server traits are generated.
fn main() {
let protos = ["../proto/st-peter-auth.proto"];
tonic_prost_build::configure()
.build_server(false)
.compile_protos(&protos, &["../proto"])
.expect("failed to compile st-peter protos");
for p in protos {
println!("cargo:rerun-if-changed={p}");
}
}

261
rust/src/lib.rs Normal file
View File

@ -0,0 +1,261 @@
//! Official Rust client for st-peter (aura-users) — the central
//! authentication service.
//!
//! The proto stubs are generated at build time from the vendored
//! `.proto` file in this repo's `proto/` directory (the st-peter
//! server repo is the source of truth — see `scripts/sync-protos.sh`).
//! The raw wire surface lives under [`authpb`]; [`AuthClient`] is the
//! ergonomic wrapper layered on top.
//!
//! ## 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 (media roles, CMS roles, …) 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 crate deliberately ships no session/permission types.
//!
//! ## Usage
//!
//! ```ignore
//! let auth = st_peter_client::AuthClient::connect("http://127.0.0.1:9091").await?;
//!
//! // per-request: verify a session token (cached ~60s)
//! let user = auth.verify_token(&token).await?;
//!
//! // login bridge
//! match auth.login(&identifier, &password).await? {
//! LoginOutcome::Authenticated(user) => { /* set session cookie from user.token */ }
//! LoginOutcome::TwoFactor { two_factor_id } => { /* collect OTP, verify_two_factor */ }
//! LoginOutcome::Failed(msg) => { /* show msg */ }
//! }
//! ```
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tonic::transport::Channel;
/// Auth proto stubs (`AuthService` — the external client surface).
/// Generated from `proto/st-peter-auth.proto` (package `st_peter.auth`).
pub mod authpb {
tonic::include_proto!("st_peter.auth");
}
use authpb::auth_service_client::AuthServiceClient;
use authpb::{
AuthenticatedUser, AuthenticationResponse, LoginRequest, LookupUserRequest, User,
VerifyTokenRequest, VerifyTwoFactorRequest,
};
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The token failed verification (or the response carried no user).
#[error("unauthorized")]
Unauthorized,
/// RPC-level failure talking to aura-users.
#[error(transparent)]
Rpc(#[from] tonic::Status),
/// The endpoint URL failed to parse (it must carry an `http://` or
/// `https://` scheme — a bare `host:port` does not).
#[error("invalid aura-users endpoint: {0}")]
InvalidUri(#[from] http::uri::InvalidUri),
}
/// TTL for cached token verifications (seconds). A revoked token stays
/// "valid" for up to this long — logout flows must not rely on the cache.
const AUTH_CACHE_TTL_SECS: u64 = 60;
/// Session cookie name issued by aura-users on login. Shared convention
/// across the aura services so a session works on any of them.
pub const COOKIE_NAME: &str = "aura_session";
/// Outcome of a `Login` (or `VerifyTwoFactor`) call against aura-users.
pub enum LoginOutcome {
/// Authenticated — carries the session (token + user + platform roles).
/// A login bridge sets the [`COOKIE_NAME`] cookie from `user.token`.
Authenticated(Box<AuthenticatedUser>),
/// aura-users issued a one-time passcode challenge (emailed/SMS'd). The
/// caller collects the code and calls
/// [`AuthClient::verify_two_factor`] with this `two_factor_id`.
TwoFactor { two_factor_id: String },
/// Login failed (bad credentials, locked out, …) — carries a display message.
Failed(String),
}
/// Map a st-peter `AuthenticationResponse` to a [`LoginOutcome`] (shared by
/// `login` and `verify_two_factor`): 2FA-required first, then success, else fail.
fn interpret_auth_response(resp: AuthenticationResponse) -> LoginOutcome {
if resp.pass_code_required {
return LoginOutcome::TwoFactor {
two_factor_id: resp.two_factor_id,
};
}
if resp.success {
if let Some(user) = resp.authenticated_user {
return LoginOutcome::Authenticated(Box::new(user));
}
}
LoginOutcome::Failed(if resp.message.is_empty() {
"Invalid email or password.".to_string()
} else {
resp.message
})
}
/// Thin client to aura-users' `AuthService`, with a small token-verify cache.
///
/// `AuthServiceClient<Channel>` is cheap to `clone` (a `Channel` is an
/// `Arc`'d connection handle), so we keep one and `.clone()` it per call
/// instead of locking a `&mut` client across an `.await`. `AuthClient`
/// itself is `Clone` and safe to share across tasks.
#[derive(Clone)]
pub struct AuthClient {
inner: AuthServiceClient<Channel>,
cache: Arc<RwLock<HashMap<u64, (AuthenticatedUser, std::time::Instant)>>>,
}
impl AuthClient {
/// Connect to aura-users gRPC (internal address, e.g. `http://127.0.0.1:9091`).
///
/// The URL must carry an `http://`/`https://` scheme — a bare `host:port`
/// fails to parse. The dial is lazy with explicit timeouts, so a
/// slow/absent aura-users at boot doesn't block a consumer's startup;
/// failures surface on the first call instead.
pub async fn connect(grpc_url: &str) -> Result<Self> {
let channel = Channel::from_shared(grpc_url.to_string())?
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(10))
.connect_lazy();
let inner = AuthServiceClient::new(channel);
Ok(Self {
inner,
cache: Arc::new(RwLock::new(HashMap::new())),
})
}
/// Verify a session token, returning the authenticated user (+ platform
/// roles). Cached ~60s to avoid a gRPC round-trip per request.
pub async fn verify_token(&self, token: &str) -> Result<AuthenticatedUser> {
let key = token_hash(token);
let ttl = std::time::Duration::from_secs(AUTH_CACHE_TTL_SECS);
// Read under the lock, clone out, and drop the guard before any `.await`.
if let Some(u) = {
let cache = self.cache.read().await;
cache
.get(&key)
.and_then(|(u, at)| (at.elapsed() < ttl).then(|| u.clone()))
} {
return Ok(u);
}
let mut client = self.inner.clone();
let resp = client
.verify_token(VerifyTokenRequest {
token: token.to_string(),
include_user_roles: true, // platform roles; service roles stay local
role_scopes: vec![],
role_names: vec![],
})
.await?
.into_inner();
// Gate on `success` first, then unwrap the option. The verified user
// is carried on `AuthenticationResponse.authenticated_user`.
if !resp.success {
return Err(Error::Unauthorized);
}
let user = resp.authenticated_user.ok_or(Error::Unauthorized)?;
self.cache
.write()
.await
.insert(key, (user.clone(), std::time::Instant::now()));
Ok(user)
}
/// Authenticate by identifier (email/phone) + password (`Login` RPC).
/// Returns `TwoFactor` when aura-users requires an OTP step,
/// `Authenticated` (with a session token) on success, or `Failed`.
pub async fn login(&self, identifier: &str, password: &str) -> Result<LoginOutcome> {
let mut client = self.inner.clone();
let resp = client
.login(LoginRequest {
user_identifier: identifier.to_string(),
password: password.to_string(),
app_hash: String::new(),
device_info: None,
})
.await?
.into_inner();
Ok(interpret_auth_response(resp))
}
/// Complete a two-factor challenge with the OTP code (`VerifyTwoFactor`
/// RPC), returning the authenticated session on success.
pub async fn verify_two_factor(&self, two_factor_id: &str, code: &str) -> Result<LoginOutcome> {
let mut client = self.inner.clone();
let resp = client
.verify_two_factor(VerifyTwoFactorRequest {
two_factor_id: two_factor_id.to_string(),
code: code.to_string(),
device_info: None,
})
.await?
.into_inner();
Ok(interpret_auth_response(resp))
}
/// Resolve a st-peter user by exact identifier (email / phone / handle).
///
/// Typical use: a service-admin role-grant page — resolve a colleague's
/// email to a st-peter `user_id`, then store a local role row referencing
/// that id by value.
///
/// `LookupUser` requires an authenticated actor — pass the acting user's
/// own `user_id` + session `token`. Returns `None` when not found.
pub async fn lookup_user(
&self,
actor_user_id: &str,
actor_token: &str,
identifier: &str,
) -> Result<Option<User>> {
let mut client = self.inner.clone();
let resp = client
.lookup_user(LookupUserRequest {
user_id: actor_user_id.to_string(),
user_token: actor_token.to_string(),
identifier: identifier.to_string(),
})
.await?
.into_inner();
if !resp.success {
return Ok(None);
}
Ok(resp.user)
}
}
/// Extract a `Bearer <token>` from an `Authorization` header map. The shared
/// convention across aura services is: Bearer header first, then the
/// [`COOKIE_NAME`] session cookie.
pub fn bearer(headers: &http::HeaderMap) -> Option<String> {
headers
.get(http::header::AUTHORIZATION)?
.to_str()
.ok()?
.strip_prefix("Bearer ")
.map(|s| s.trim().to_string())
}
/// Fast (non-cryptographic) hash of the token, used only as the cache key.
fn token_hash(token: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
token.hash(&mut h);
h.finish()
}

32
scripts/gen-go.sh Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Generate Go gRPC stubs from the vendored proto into go/genpb/auth.
# The proto's in-file `option go_package` targets the server's module; the
# M-flag mapping here overrides it for this client module.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$here/go"
export PATH="$(go env GOPATH)/bin:$PATH"
MOD=git.awesomike.com/pub/st-peter-client/go
# proto filename (relative to ../proto) -> Go package dir under the module
protos=(
st-peter-auth.proto:genpb/auth
)
files=()
mflags=()
for entry in "${protos[@]}"; do
f="${entry%%:*}"; pkg="${entry##*:}"
files+=("$f")
mflags+=("--go_opt=M$f=$MOD/$pkg" "--go-grpc_opt=M$f=$MOD/$pkg")
done
rm -rf genpb
protoc -I ../proto \
--go_out=. --go_opt=module="$MOD" \
--go-grpc_out=. --go-grpc_opt=module="$MOD" \
"${mflags[@]}" \
"${files[@]}"
echo "Generated Go stubs under go/genpb/"

24
scripts/gen-ts.sh Executable file
View File

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Generate TypeScript gRPC stubs from the vendored proto into ts/src/genpb,
# using ts-proto with @grpc/grpc-js service output.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$here/ts"
plugin="node_modules/.bin/protoc-gen-ts_proto"
if [[ ! -x "$plugin" ]]; then
echo "ts-proto not installed; run 'npm install' in ts/ first" >&2
exit 1
fi
out="src/genpb"
rm -rf "$out"; mkdir -p "$out"
protoc -I ../proto \
--plugin=protoc-gen-ts_proto="$plugin" \
--ts_proto_out="$out" \
--ts_proto_opt=outputServices=grpc-js,esModuleInterop=true,env=node,useExactTypes=false,unrecognizedEnum=false \
st-peter-auth.proto
echo "Generated TypeScript stubs under ts/src/genpb/"

51
scripts/sync-protos.sh Executable file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env bash
#
# Sync the canonical .proto files from the st-peter server repo into this
# client repo's proto/ directory.
#
# The st-peter server repo is the SOURCE OF TRUTH for the wire contract.
# This repo vendors copies so the Go / TS / Rust clients can each generate
# stubs without depending on the server's Cargo workspace. Run this whenever
# the server's protos change, then re-run codegen and tag at the SAME version
# as the st-peter release (see VERSION).
#
# Usage:
# ST_PETER_REPO=/path/to/st-peter-lib ./scripts/sync-protos.sh
# Defaults to ../st-peter-lib relative to this repo.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ST_PETER_REPO="${ST_PETER_REPO:-$here/../st-peter-lib}"
if [[ ! -f "$ST_PETER_REPO/Cargo.toml" ]]; then
echo "ERROR: st-peter repo not found at '$ST_PETER_REPO'." >&2
echo " Set ST_PETER_REPO=/path/to/st-peter-lib and re-run." >&2
exit 1
fi
# canonical path in st-peter-lib -> filename in proto/
# NOTE: st-peter-admin.proto (the admin surface) and health.proto are
# intentionally NOT vendored — services authenticate users; they do not
# administer st-peter.
protos=(
"proto/st-peter-auth.proto"
)
dest="$here/proto"
mkdir -p "$dest"
for rel in "${protos[@]}"; do
src="$ST_PETER_REPO/$rel"
[[ -f "$src" ]] || { echo "ERROR: missing $src" >&2; exit 1; }
cp "$src" "$dest/$(basename "$rel")"
echo "synced $(basename "$rel")"
done
# Keep VERSION in lockstep with the st-peter workspace version.
sp_version="$(grep -m1 '^version' "$ST_PETER_REPO/Cargo.toml" | sed -E 's/.*"([^"]+)".*/\1/')"
if [[ -n "$sp_version" ]]; then
echo "$sp_version" > "$here/VERSION"
echo "VERSION -> $sp_version (matched st-peter workspace)"
fi
echo "Done. Re-run codegen (scripts/gen-*.sh) and tag at v$(cat "$here/VERSION")."

14
ts/README.md Normal file
View File

@ -0,0 +1,14 @@
# @st-peter/client (TypeScript)
Official TypeScript client for st-peter (aura-users). Generated stubs are
committed under `src/genpb/` (regenerate with `npm run gen`); the
`StPeterAuthClient` wrapper (token-verify cache, login/2FA/lookup) is layered
on top, with the raw wire surface re-exported as `authpb`.
```ts
const auth = StPeterAuthClient.connect("127.0.0.1:9091");
const user = await auth.verifyToken(token); // cached ~60s
```
See the repo root README for versioning and the
authentication-central / authorization-local design.

447
ts/package-lock.json generated Normal file
View File

@ -0,0 +1,447 @@
{
"name": "@st-peter/client",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@st-peter/client",
"version": "0.2.0",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@grpc/grpc-js": "^1.12.0",
"long": "^5.2.3"
},
"devDependencies": {
"@types/node": "^22.0.0",
"ts-proto": "^2.6.0",
"typescript": "^5.6.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz",
"integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==",
"dev": true,
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz",
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
},
"engines": {
"node": ">=12.10.0"
}
},
"node_modules/@grpc/proto-loader": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz",
"integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==",
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.5.5",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@types/node": {
"version": "22.19.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz",
"integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/case-anything": {
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz",
"integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/dprint-node": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/dprint-node/-/dprint-node-1.0.8.tgz",
"integrity": "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^1.0.3"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/protobufjs": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz",
"integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ts-poet": {
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz",
"integrity": "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dprint-node": "^1.0.8"
}
},
"node_modules/ts-proto": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.11.8.tgz",
"integrity": "sha512-+5hzECnyVB33jxjG1BIdzAHcRBm7hjnm8womdJVp2A7xJWihP0drHHVsXYTr9i/LpWNGfh80I+AVVNzFM5AwJw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bufbuild/protobuf": "^2.10.2",
"case-anything": "^2.1.13",
"ts-poet": "^6.12.0",
"ts-proto-descriptors": "2.1.0"
},
"bin": {
"protoc-gen-ts_proto": "protoc-gen-ts_proto"
}
},
"node_modules/ts-proto-descriptors": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-2.1.0.tgz",
"integrity": "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bufbuild/protobuf": "^2.0.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

25
ts/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "@st-peter/client",
"version": "0.2.0",
"description": "Official TypeScript client for st-peter (aura-users) — authentication over gRPC with a token-verify cache",
"repository": "https://git.awesomike.com/pub/st-peter-client",
"license": "MIT OR Apache-2.0",
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist", "src"],
"scripts": {
"gen": "../scripts/gen-ts.sh",
"build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@grpc/grpc-js": "^1.12.0",
"long": "^5.2.3"
},
"devDependencies": {
"ts-proto": "^2.6.0",
"typescript": "^5.6.0",
"@types/node": "^22.0.0"
}
}

202
ts/src/auth.ts Normal file
View File

@ -0,0 +1,202 @@
/**
* Official TypeScript 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.
*
* ```ts
* const auth = StPeterAuthClient.connect("127.0.0.1:9091");
* const user = await auth.verifyToken(token); // cached ~60s
* ```
*/
import * as grpc from "@grpc/grpc-js";
import {
AuthServiceClient,
AuthenticatedUser,
AuthenticationResponse,
LoginRequest,
LookupUserRequest,
LookupUserResponse,
User,
VerifyTokenRequest,
VerifyTwoFactorRequest,
} from "./genpb/st-peter-auth";
/**
* Session cookie name issued by aura-users on login. Shared convention
* across the aura services so a session works on any of them.
*/
export const COOKIE_NAME = "aura_session";
/**
* TTL for cached token verifications. A revoked token stays "valid" for up
* to this long logout flows must not rely on the cache.
*/
const AUTH_CACHE_TTL_MS = 60_000;
/** Thrown when a token fails verification (or the response carries no user). */
export class UnauthorizedError extends Error {
constructor() {
super("unauthorized");
this.name = "UnauthorizedError";
}
}
/** Outcome of a `login` (or `verifyTwoFactor`) call against aura-users. */
export type LoginOutcome =
/** Authenticated — a login bridge sets the COOKIE_NAME cookie from `user.token`. */
| { kind: "authenticated"; user: AuthenticatedUser }
/** aura-users issued an OTP challenge; collect the code, call `verifyTwoFactor`. */
| { kind: "two_factor"; twoFactorId: string }
/** Login failed (bad credentials, locked out, …) — carries a display message. */
| { kind: "failed"; message: string };
/**
* Map a st-peter AuthenticationResponse to a LoginOutcome (shared by
* `login` and `verifyTwoFactor`): 2FA-required first, then success, else fail.
*/
function interpretAuthResponse(resp: AuthenticationResponse): LoginOutcome {
if (resp.passCodeRequired) {
return { kind: "two_factor", twoFactorId: resp.twoFactorId };
}
if (resp.success && resp.authenticatedUser) {
return { kind: "authenticated", user: resp.authenticatedUser };
}
return {
kind: "failed",
message: resp.message || "Invalid email or password.",
};
}
/** Options for `StPeterAuthClient.connect`. */
export interface ConnectOptions {
/** Channel credentials. Defaults to `grpc.credentials.createInsecure()`. */
credentials?: grpc.ChannelCredentials;
/** Extra gRPC channel options. */
channelOptions?: grpc.ChannelOptions;
}
/** Promisify a unary grpc-js call. */
function call<Req, Res>(
fn: (req: Req, cb: (err: grpc.ServiceError | null, res: Res) => void) => unknown,
req: Req
): Promise<Res> {
return new Promise((resolve, reject) =>
fn(req, (err, res) => (err ? reject(err) : resolve(res)))
);
}
/**
* Thin client to aura-users' AuthService with a small token-verify cache.
* Safe to share across async tasks the underlying grpc-js channel is
* reference-counted.
*/
export class StPeterAuthClient {
private readonly svc: AuthServiceClient;
private readonly cache = new Map<string, { user: AuthenticatedUser; at: number }>();
private constructor(svc: AuthServiceClient) {
this.svc = svc;
}
/**
* Connect to aura-users gRPC (internal address, e.g. `127.0.0.1:9091`
* grpc-js targets carry no scheme). The dial is lazy: failures surface on
* the first call, not at connect time.
*/
static connect(target: string, opts: ConnectOptions = {}): StPeterAuthClient {
const creds = opts.credentials ?? grpc.credentials.createInsecure();
return new StPeterAuthClient(new AuthServiceClient(target, creds, opts.channelOptions));
}
/** Tear down the underlying gRPC channel. */
close(): void {
this.svc.close();
}
/**
* Verify a session token, returning the authenticated user (+ platform
* roles). Cached ~60s to avoid a gRPC round-trip per request. Throws
* `UnauthorizedError` on a bad token.
*/
async verifyToken(token: string): Promise<AuthenticatedUser> {
const hit = this.cache.get(token);
if (hit && Date.now() - hit.at < AUTH_CACHE_TTL_MS) {
return hit.user;
}
const resp = await call<VerifyTokenRequest, AuthenticationResponse>(
this.svc.verifyToken.bind(this.svc),
VerifyTokenRequest.fromPartial({
token,
includeUserRoles: true, // platform roles; service roles stay local
})
);
// Gate on `success` first, then the user. The verified user is carried
// on AuthenticationResponse.authenticated_user.
if (!resp.success || !resp.authenticatedUser) {
throw new UnauthorizedError();
}
this.cache.set(token, { user: resp.authenticatedUser, at: Date.now() });
return resp.authenticatedUser;
}
/** Authenticate by identifier (email/phone) + password (`Login` RPC). */
async login(identifier: string, password: string): Promise<LoginOutcome> {
const resp = await call<LoginRequest, AuthenticationResponse>(
this.svc.login.bind(this.svc),
LoginRequest.fromPartial({ userIdentifier: identifier, password })
);
return interpretAuthResponse(resp);
}
/**
* Complete a two-factor challenge with the OTP code, returning the
* authenticated session on success.
*/
async verifyTwoFactor(twoFactorId: string, code: string): Promise<LoginOutcome> {
const resp = await call<VerifyTwoFactorRequest, AuthenticationResponse>(
this.svc.verifyTwoFactor.bind(this.svc),
VerifyTwoFactorRequest.fromPartial({ twoFactorId, code })
);
return interpretAuthResponse(resp);
}
/**
* Resolve a st-peter user by exact identifier (email / phone / handle).
* Requires an authenticated actor pass the acting user's own user id +
* session token. Returns `undefined` when not found.
*/
async lookupUser(
actorUserId: string,
actorToken: string,
identifier: string
): Promise<User | undefined> {
const resp = await call<LookupUserRequest, LookupUserResponse>(
this.svc.lookupUser.bind(this.svc),
LookupUserRequest.fromPartial({
userId: actorUserId,
userToken: actorToken,
identifier,
})
);
return resp.success ? resp.user : undefined;
}
}
/**
* Extract a `Bearer <token>` from an Authorization header value. The shared
* convention across aura services is: Bearer header first, then the
* COOKIE_NAME session cookie.
*/
export function bearer(authorization: string | undefined): string | undefined {
if (!authorization?.startsWith("Bearer ")) return undefined;
return authorization.slice("Bearer ".length).trim();
}

View File

@ -0,0 +1,226 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.11.8
// protoc v7.34.1
// source: google/protobuf/timestamp.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
export const protobufPackage = "google.protobuf";
/**
* A Timestamp represents a point in time independent of any time zone or local
* calendar, encoded as a count of seconds and fractions of seconds at
* nanosecond resolution. The count is relative to an epoch at UTC midnight on
* January 1, 1970, in the proleptic Gregorian calendar which extends the
* Gregorian calendar backwards to year one.
*
* All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap
* second table is needed for interpretation, using a [24-hour linear
* smear](https://developers.google.com/time/smear).
*
* The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By
* restricting to that range, we ensure that we can convert to and from [RFC
* 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings.
*
* # Examples
*
* Example 1: Compute Timestamp from POSIX `time()`.
*
* Timestamp timestamp;
* timestamp.set_seconds(time(NULL));
* timestamp.set_nanos(0);
*
* Example 2: Compute Timestamp from POSIX `gettimeofday()`.
*
* struct timeval tv;
* gettimeofday(&tv, NULL);
*
* Timestamp timestamp;
* timestamp.set_seconds(tv.tv_sec);
* timestamp.set_nanos(tv.tv_usec * 1000);
*
* Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`.
*
* FILETIME ft;
* GetSystemTimeAsFileTime(&ft);
* UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime;
*
* // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z
* // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z.
* Timestamp timestamp;
* timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL));
* timestamp.set_nanos((INT32) ((ticks % 10000000) * 100));
*
* Example 4: Compute Timestamp from Java `System.currentTimeMillis()`.
*
* long millis = System.currentTimeMillis();
*
* Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000)
* .setNanos((int) ((millis % 1000) * 1000000)).build();
*
* Example 5: Compute Timestamp from Java `Instant.now()`.
*
* Instant now = Instant.now();
*
* Timestamp timestamp =
* Timestamp.newBuilder().setSeconds(now.getEpochSecond())
* .setNanos(now.getNano()).build();
*
* Example 6: Compute Timestamp from current time in Python.
*
* timestamp = Timestamp()
* timestamp.GetCurrentTime()
*
* # JSON Mapping
*
* In JSON format, the Timestamp type is encoded as a string in the
* [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the
* format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z"
* where {year} is always expressed using four digits while {month}, {day},
* {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional
* seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution),
* are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone
* is required. A ProtoJSON serializer should always use UTC (as indicated by
* "Z") when printing the Timestamp type and a ProtoJSON parser should be
* able to accept both UTC and other timezones (as indicated by an offset).
*
* For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past
* 01:30 UTC on January 15, 2017.
*
* In JavaScript, one can convert a Date object to this format using the
* standard
* [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString)
* method. In Python, a standard `datetime.datetime` object can be converted
* to this format using
* [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with
* the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use
* the Joda Time's [`ISODateTimeFormat.dateTime()`](
* http://joda-time.sourceforge.net/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime()
* ) to obtain a formatter capable of generating timestamps in this format.
*/
export interface Timestamp {
/**
* Represents seconds of UTC time since Unix epoch 1970-01-01T00:00:00Z. Must
* be between -62135596800 and 253402300799 inclusive (which corresponds to
* 0001-01-01T00:00:00Z to 9999-12-31T23:59:59Z).
*/
seconds: number;
/**
* Non-negative fractions of a second at nanosecond resolution. This field is
* the nanosecond portion of the duration, not an alternative to seconds.
* Negative second values with fractions must still have non-negative nanos
* values that count forward in time. Must be between 0 and 999,999,999
* inclusive.
*/
nanos: number;
}
function createBaseTimestamp(): Timestamp {
return { seconds: 0, nanos: 0 };
}
export const Timestamp: MessageFns<Timestamp> = {
encode(message: Timestamp, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.seconds !== 0) {
writer.uint32(8).int64(message.seconds);
}
if (message.nanos !== 0) {
writer.uint32(16).int32(message.nanos);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): Timestamp {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseTimestamp();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.seconds = longToNumber(reader.int64());
continue;
}
case 2: {
if (tag !== 16) {
break;
}
message.nanos = reader.int32();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): Timestamp {
return {
seconds: isSet(object.seconds) ? globalThis.Number(object.seconds) : 0,
nanos: isSet(object.nanos) ? globalThis.Number(object.nanos) : 0,
};
},
toJSON(message: Timestamp): unknown {
const obj: any = {};
if (message.seconds !== 0) {
obj.seconds = Math.round(message.seconds);
}
if (message.nanos !== 0) {
obj.nanos = Math.round(message.nanos);
}
return obj;
},
create(base?: DeepPartial<Timestamp>): Timestamp {
return Timestamp.fromPartial(base ?? {});
},
fromPartial(object: DeepPartial<Timestamp>): Timestamp {
const message = createBaseTimestamp();
message.seconds = object.seconds ?? 0;
message.nanos = object.nanos ?? 0;
return message;
},
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial<T> = T extends Builtin ? T
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
function longToNumber(int64: { toString(): string }): number {
const num = globalThis.Number(int64.toString());
if (num > globalThis.Number.MAX_SAFE_INTEGER) {
throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER");
}
if (num < globalThis.Number.MIN_SAFE_INTEGER) {
throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER");
}
return num;
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create(base?: DeepPartial<T>): T;
fromPartial(object: DeepPartial<T>): T;
}

10756
ts/src/genpb/st-peter-auth.ts Normal file

File diff suppressed because it is too large Load Diff

10
ts/src/index.ts Normal file
View File

@ -0,0 +1,10 @@
export {
StPeterAuthClient,
UnauthorizedError,
COOKIE_NAME,
bearer,
} from "./auth";
export type { LoginOutcome, ConnectOptions } from "./auth";
// Raw wire surface for callers that need it.
export * as authpb from "./genpb/st-peter-auth";

18
ts/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2020"],
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}