328 lines
10 KiB
Go
328 lines
10 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
|
"github.com/go-chi/chi/v5"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/clientip"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// OAuthClient is the host-side gRPC client surface the OAuth handler needs.
|
|
// Defined as an interface so handler tests can substitute a fake.
|
|
type OAuthClient interface {
|
|
InitAuthorize(ctx context.Context, req *pluginv1.InitAuthorizeRequest) (*pluginv1.InitAuthorizeResponse, error)
|
|
ExchangeCode(ctx context.Context, req *pluginv1.ExchangeCodeRequest) (*pluginv1.AuthenticateResponse, error)
|
|
}
|
|
|
|
// OAuthLoginCompleter wraps the post-ExchangeCode work: lookup or provision
|
|
// the user identified by the AuthenticateResponse, create a session, mint a
|
|
// token pair. Defined as an interface so tests can avoid spinning up the
|
|
// full auth.Service.
|
|
type OAuthLoginCompleter interface {
|
|
CompleteOAuthLogin(ctx context.Context, in OAuthLoginInput) (*TokenPair, *models.User, error)
|
|
}
|
|
|
|
// OAuthLoginInput carries everything the completer needs to issue a session.
|
|
type OAuthLoginInput struct {
|
|
InstallationID int
|
|
CapabilityID string
|
|
Response *pluginv1.AuthenticateResponse
|
|
LinkingUserID int // 0 = not linking
|
|
DeviceName string
|
|
IP string
|
|
}
|
|
|
|
// OAuthHandlerDeps wires the OAuthHandler. ResolveClient turns the URL's
|
|
// installation_id into a plugin gRPC client; Provisioner consumes the
|
|
// AuthenticateResponse and produces a session.
|
|
type OAuthHandlerDeps struct {
|
|
Store OAuthStore
|
|
CompletionStore OAuthCompletionStore
|
|
StateSecret []byte
|
|
ResolveClient func(ctx context.Context, installationID int) (OAuthClient, string, error) // returns (client, capabilityID, err)
|
|
LoginCompleter OAuthLoginCompleter
|
|
HostBaseURL string
|
|
StateTTL time.Duration
|
|
// FrontendCompletePath is the SPA path the callback redirects to after
|
|
// minting a one-time completion code. The SPA exchanges that code for tokens.
|
|
FrontendCompletePath string
|
|
}
|
|
|
|
// OAuthHandler serves /init and /callback for OAuth-capable auth plugins.
|
|
type OAuthHandler struct {
|
|
deps OAuthHandlerDeps
|
|
}
|
|
|
|
func NewOAuthHandler(d OAuthHandlerDeps) *OAuthHandler {
|
|
if d.StateTTL == 0 {
|
|
d.StateTTL = 10 * time.Minute
|
|
}
|
|
if d.FrontendCompletePath == "" {
|
|
d.FrontendCompletePath = "/login/oauth-complete"
|
|
}
|
|
if d.CompletionStore == nil {
|
|
if store, ok := d.Store.(OAuthCompletionStore); ok {
|
|
d.CompletionStore = store
|
|
}
|
|
}
|
|
return &OAuthHandler{deps: d}
|
|
}
|
|
|
|
// ErrMissingInstallID is returned when the URL path has no install_id.
|
|
var ErrMissingInstallID = errors.New("install_id required")
|
|
|
|
// HandleInit serves POST /api/v1/auth/oauth/{install_id}/init.
|
|
func (h *OAuthHandler) HandleInit(w http.ResponseWriter, r *http.Request) {
|
|
installID, err := strconv.Atoi(chi.URLParam(r, "install_id"))
|
|
if err != nil || installID <= 0 {
|
|
http.Error(w, "invalid install_id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
next := normalizeOAuthNext(r.URL.Query().Get("next"))
|
|
|
|
client, _, err := h.deps.ResolveClient(r.Context(), installID)
|
|
if err != nil {
|
|
http.Error(w, "auth plugin unavailable", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
nonce, err := randomHex(16)
|
|
if err != nil {
|
|
http.Error(w, "rand failure", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
state := SignState(h.deps.StateSecret, StatePayload{
|
|
Nonce: nonce,
|
|
InstallID: strconv.Itoa(installID),
|
|
ExpiresAt: now.Add(h.deps.StateTTL),
|
|
})
|
|
redirectURI := strings.TrimRight(h.deps.HostBaseURL, "/") + "/api/v1/auth/oauth/" + strconv.Itoa(installID) + "/callback"
|
|
|
|
resp, err := client.InitAuthorize(r.Context(), &pluginv1.InitAuthorizeRequest{
|
|
RedirectUri: redirectURI,
|
|
State: state,
|
|
// Linking is wired in a follow-up — see TODO below.
|
|
})
|
|
if err != nil {
|
|
slog.Warn("oauth init_authorize failed", "installation_id", installID, "error", err)
|
|
http.Error(w, "plugin init_authorize failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
if resp.GetAuthorizeUrl() == "" {
|
|
http.Error(w, "plugin returned empty authorize_url", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
psBytes, _ := json.Marshal(resp.GetProviderState().AsMap())
|
|
sess := OAuthSession{
|
|
State: state,
|
|
InstallID: strconv.Itoa(installID),
|
|
RedirectURI: redirectURI,
|
|
ProviderState: psBytes,
|
|
NextURL: next,
|
|
ExpiresAt: now.Add(h.deps.StateTTL),
|
|
// TODO: when linking flow lands, read user_id from existing session
|
|
// and set LinkingUserID here.
|
|
}
|
|
if err := h.deps.Store.Insert(r.Context(), sess); err != nil {
|
|
slog.Warn("oauth session insert failed", "installation_id", installID, "error", err)
|
|
http.Error(w, "store insert failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, resp.GetAuthorizeUrl(), http.StatusFound)
|
|
}
|
|
|
|
// HandleCallback serves GET /api/v1/auth/oauth/{install_id}/callback.
|
|
func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
|
installID, err := strconv.Atoi(chi.URLParam(r, "install_id"))
|
|
if err != nil || installID <= 0 {
|
|
http.Error(w, "invalid install_id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
state := r.URL.Query().Get("state")
|
|
code := r.URL.Query().Get("code")
|
|
if state == "" || code == "" {
|
|
http.Error(w, "missing code or state", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
payload, err := VerifyState(h.deps.StateSecret, state)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=state_invalid", http.StatusFound)
|
|
return
|
|
}
|
|
if payload.InstallID != strconv.Itoa(installID) {
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=install_mismatch", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
sess, err := h.deps.Store.GetAndDelete(r.Context(), state)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=session_expired", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
client, capabilityID, err := h.deps.ResolveClient(r.Context(), installID)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=plugin_unavailable", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
var ps map[string]any
|
|
_ = json.Unmarshal(sess.ProviderState, &ps)
|
|
psStruct, _ := structpb.NewStruct(ps)
|
|
|
|
resp, err := client.ExchangeCode(r.Context(), &pluginv1.ExchangeCodeRequest{
|
|
Code: code,
|
|
State: state,
|
|
RedirectUri: sess.RedirectURI,
|
|
ProviderState: psStruct,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("oauth exchange_code failed", "installation_id", installID, "error", err)
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=exchange_failed", http.StatusFound)
|
|
return
|
|
}
|
|
if resp.GetExternalSubject() == "" {
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=empty_subject", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
linkingUserID := 0
|
|
if sess.LinkingUserID != "" {
|
|
if uid, err := strconv.Atoi(sess.LinkingUserID); err == nil {
|
|
linkingUserID = uid
|
|
}
|
|
}
|
|
|
|
pair, _, err := h.deps.LoginCompleter.CompleteOAuthLogin(r.Context(), OAuthLoginInput{
|
|
InstallationID: installID,
|
|
CapabilityID: capabilityID,
|
|
Response: resp,
|
|
LinkingUserID: linkingUserID,
|
|
DeviceName: r.UserAgent(),
|
|
IP: clientIP(r),
|
|
})
|
|
if err != nil {
|
|
slog.Warn("oauth login completion failed", "installation_id", installID, "error", err)
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=login_failed", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
if h.deps.CompletionStore == nil {
|
|
slog.Warn("oauth completion store is unavailable", "installation_id", installID)
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_unavailable", http.StatusFound)
|
|
return
|
|
}
|
|
completionCode, err := randomHex(32)
|
|
if err != nil {
|
|
slog.Warn("oauth completion code generation failed", "installation_id", installID, "error", err)
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_failed", http.StatusFound)
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := h.deps.CompletionStore.InsertCompletion(r.Context(), OAuthCompletion{
|
|
Code: completionCode,
|
|
AccessToken: pair.AccessToken,
|
|
RefreshToken: pair.RefreshToken,
|
|
ExpiresIn: pair.ExpiresIn,
|
|
NextURL: sess.NextURL,
|
|
ExpiresAt: now.Add(time.Minute),
|
|
}); err != nil {
|
|
slog.Warn("oauth completion insert failed", "installation_id", installID, "error", err)
|
|
http.Redirect(w, r, "/login?error=oauth_failed&reason=completion_failed", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
values := url.Values{}
|
|
values.Set("code", completionCode)
|
|
completeURL := strings.TrimRight(h.deps.HostBaseURL, "/") + h.deps.FrontendCompletePath + "?" + values.Encode()
|
|
http.Redirect(w, r, completeURL, http.StatusFound)
|
|
}
|
|
|
|
type OAuthCompleteRequest struct {
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
type OAuthCompleteResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
NextURL string `json:"next"`
|
|
}
|
|
|
|
func (h *OAuthHandler) HandleComplete(w http.ResponseWriter, r *http.Request) {
|
|
if h.deps.CompletionStore == nil {
|
|
http.Error(w, "oauth completion unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
var req OAuthCompleteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
code := strings.TrimSpace(req.Code)
|
|
if code == "" {
|
|
http.Error(w, "code required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
completion, err := h.deps.CompletionStore.GetAndDeleteCompletion(r.Context(), code)
|
|
if err != nil {
|
|
http.Error(w, "invalid or expired completion code", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(OAuthCompleteResponse{
|
|
AccessToken: completion.AccessToken,
|
|
RefreshToken: completion.RefreshToken,
|
|
ExpiresIn: completion.ExpiresIn,
|
|
NextURL: completion.NextURL,
|
|
})
|
|
}
|
|
|
|
func clientIP(r *http.Request) string {
|
|
if ip := strings.TrimSpace(clientip.FromContext(r.Context())); ip != "" {
|
|
return ip
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err == nil {
|
|
return strings.TrimSpace(host)
|
|
}
|
|
return strings.Trim(strings.TrimSpace(r.RemoteAddr), "[]")
|
|
}
|
|
|
|
func normalizeOAuthNext(next string) string {
|
|
next = strings.TrimSpace(next)
|
|
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
|
|
return "/"
|
|
}
|
|
return next
|
|
}
|
|
|
|
func randomHex(n int) (string, error) {
|
|
buf := make([]byte, n)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|