Files
sherlock/internal/broker/methods.go
T
amacocian fe71a4e60c
CI / build (push) Successful in 1m5s
Code fixes
2026-05-25 09:51:14 +02:00

227 lines
7.0 KiB
Go

package broker
import (
"context"
"encoding/json"
"errors"
"time"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/rpc"
)
// Methods bundles the broker's dependencies and exposes a MethodTable
// for socket dispatch. The fields are exported so cmd/sherlock-broker
// can construct one with real implementations and tests can swap in
// fakes (the *keyring.Store interface is the obvious seam).
type Methods struct {
Store keyring.Store
Flow *authn.Flow
Refresher *authn.Refresher
Cfg authn.Config
// ShutdownFn is invoked by the "shutdown" RPC. Wired by the daemon
// to cancel the root context.
ShutdownFn func()
// AgentNames feeds the status response so the CLI can show which
// profiles are available without re-loading them.
AgentNames func() []string
}
// MethodTable returns the map suitable for NewService.
func (m *Methods) MethodTable() map[string]MethodHandler {
return map[string]MethodHandler{
rpc.MethodStatus: m.handleStatus,
rpc.MethodWhoami: m.handleWhoami,
rpc.MethodLoginStart: m.handleLoginStart,
rpc.MethodLoginWait: m.handleLoginWait,
rpc.MethodLogout: m.handleLogout,
rpc.MethodGetIDToken: m.handleGetIDToken,
rpc.MethodShutdown: m.handleShutdown,
}
}
// loadTokens reads the keyring once and caches the result in memory.
// We re-read on every status to keep things simple — the keyring is
// the source of truth.
func (m *Methods) currentTokens() (keyring.TokenSet, bool, error) {
ts, err := m.Store.GetAuthentikTokens()
if errors.Is(err, keyring.ErrNoTokens) {
return keyring.TokenSet{}, false, nil
}
if err != nil {
return keyring.TokenSet{}, false, err
}
return ts, !ts.Empty(), nil
}
// StatusResult is what the status RPC returns.
type StatusResult struct {
LoggedIn bool `json:"logged_in"`
Subject string `json:"sub,omitempty"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
IDExpiresAt *time.Time `json:"id_expires_at,omitempty"`
RefreshExpAt *time.Time `json:"refresh_expires_at,omitempty"`
Issuer string `json:"issuer,omitempty"`
AuthnConfigured bool `json:"authn_configured"`
Agents []string `json:"agents"`
}
func ptrTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
tt := t
return &tt
}
func (m *Methods) handleStatus(_ context.Context, _ json.RawMessage) (any, *rpc.Error) {
ts, ok, err := m.currentTokens()
if err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "read keyring: %v", err)
}
out := StatusResult{
LoggedIn: ok,
AuthnConfigured: m.Cfg.Configured(),
}
if m.AgentNames != nil {
out.Agents = m.AgentNames()
}
if ok {
out.Subject = ts.Subject
out.Name = ts.Name
out.Email = ts.Email
out.Issuer = ts.Issuer
out.IDExpiresAt = ptrTime(ts.IDExpiresAt)
out.RefreshExpAt = ptrTime(ts.RefreshExpAt)
}
return out, nil
}
// WhoamiResult is what the whoami RPC returns.
type WhoamiResult struct {
Subject string `json:"sub"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
}
func (m *Methods) handleWhoami(_ context.Context, _ json.RawMessage) (any, *rpc.Error) {
ts, ok, err := m.currentTokens()
if err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "read keyring: %v", err)
}
if !ok {
return nil, rpc.NewError(rpc.CodeNotLoggedIn, "not logged in")
}
return WhoamiResult{Subject: ts.Subject, Name: ts.Name, Email: ts.Email}, nil
}
// LoginStartResult is what the login_start RPC returns.
type LoginStartResult struct {
AuthURL string `json:"auth_url"`
State string `json:"state"`
}
func (m *Methods) handleLoginStart(ctx context.Context, _ json.RawMessage) (any, *rpc.Error) {
if !m.Cfg.Configured() {
return nil, rpc.NewError(rpc.CodeNotConfigured,
"broker is not configured for Authentik yet; see plan.md Open #11 — set SHERLOCK_AUTHENTIK_ISSUER and SHERLOCK_AUTHENTIK_CLIENT_ID")
}
res, err := m.Flow.StartFlow(ctx)
if err != nil {
if errors.Is(err, authn.ErrNotConfigured) {
return nil, rpc.NewError(rpc.CodeNotConfigured, err.Error())
}
return nil, rpc.Errorf(rpc.CodeInternal, "start flow: %v", err)
}
return LoginStartResult{AuthURL: res.AuthURL, State: res.State}, nil
}
// LoginWaitParams are accepted by the login_wait RPC.
type LoginWaitParams struct {
State string `json:"state"`
TimeoutSeconds int `json:"timeout_seconds"`
}
// LoginWaitResult is what login_wait returns.
type LoginWaitResult struct {
OK bool `json:"ok"`
Subject string `json:"sub,omitempty"`
}
func (m *Methods) handleLoginWait(ctx context.Context, raw json.RawMessage) (any, *rpc.Error) {
var p LoginWaitParams
if len(raw) > 0 {
if err := json.Unmarshal(raw, &p); err != nil {
return nil, rpc.Errorf(rpc.CodeBadRequest, "decode params: %v", err)
}
}
if p.State == "" {
return nil, rpc.NewError(rpc.CodeBadRequest, "state is required")
}
timeout := time.Duration(p.TimeoutSeconds) * time.Second
res, err := m.Flow.AwaitCallback(ctx, p.State, timeout)
if err != nil {
if errors.Is(err, authn.ErrFlowExpired) {
return nil, rpc.NewError(rpc.CodeFlowExpired, err.Error())
}
if errors.Is(err, authn.ErrUnknownState) {
return nil, rpc.NewError(rpc.CodeBadRequest, err.Error())
}
return nil, rpc.Errorf(rpc.CodeInternal, "await callback: %v", err)
}
if err := m.Store.SetAuthentikTokens(res.ToTokenSet()); err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "persist tokens: %v", err)
}
return LoginWaitResult{OK: true, Subject: res.Subject}, nil
}
func (m *Methods) handleLogout(_ context.Context, _ json.RawMessage) (any, *rpc.Error) {
if err := m.Store.ClearAuthentikTokens(); err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "clear keyring: %v", err)
}
return struct{}{}, nil
}
// GetIDTokenResult is what get_id_token returns.
type GetIDTokenResult struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
func (m *Methods) handleGetIDToken(ctx context.Context, _ json.RawMessage) (any, *rpc.Error) {
ts, ok, err := m.currentTokens()
if err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "read keyring: %v", err)
}
if !ok {
return nil, rpc.NewError(rpc.CodeNotLoggedIn, "not logged in")
}
if m.Refresher != nil {
fresh, err := m.Refresher.EnsureFresh(ctx, ts)
if err != nil {
if errors.Is(err, authn.ErrNoRefreshToken) {
return nil, rpc.NewError(rpc.CodeNotLoggedIn, "refresh token missing — re-login required")
}
return nil, rpc.Errorf(rpc.CodeInternal, "refresh: %v", err)
}
if fresh.IDToken != ts.IDToken {
if err := m.Store.SetAuthentikTokens(fresh); err != nil {
return nil, rpc.Errorf(rpc.CodeInternal, "persist refreshed tokens: %v", err)
}
ts = fresh
}
}
return GetIDTokenResult{Token: ts.IDToken, ExpiresAt: ts.IDExpiresAt}, nil
}
func (m *Methods) handleShutdown(_ context.Context, _ json.RawMessage) (any, *rpc.Error) {
if m.ShutdownFn != nil {
go m.ShutdownFn() // run async so we can return a response first
}
return struct{}{}, nil
}