227 lines
7.0 KiB
Go
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
|
|
}
|