194 lines
6.2 KiB
Go
194 lines
6.2 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
const browserSessionCookie = "__Host-fancydns-session"
|
|
const loginCookie = "__Host-fancydns-login"
|
|
|
|
type oidcLogin struct {
|
|
verifier string
|
|
next string
|
|
expires time.Time
|
|
}
|
|
|
|
// The registered callback is configuration, never an untrusted Host header.
|
|
func (s *Server) browserRedirect() (*url.URL, error) {
|
|
raw := s.auth.oidcRedirectURI
|
|
if raw == "" {
|
|
u, err := url.Parse(s.auth.oidcAudience)
|
|
if err != nil || u.Scheme != "https" || u.Host == "" {
|
|
return nil, errors.New("configure api.auth.oidc_redirect_uri with the HTTPS console callback URL")
|
|
}
|
|
raw = u.Scheme + "://" + u.Host + "/auth/oidc/callback"
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
|
return nil, errors.New("OIDC callback must be an absolute HTTPS URL without query or fragment")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (s *Server) cookieRequestAllowed(r *http.Request) bool {
|
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
|
return true
|
|
}
|
|
u, err := s.browserRedirect()
|
|
return err == nil && r.Header.Get("Origin") == u.Scheme+"://"+u.Host
|
|
}
|
|
|
|
func (s *Server) setBrowserCookie(w http.ResponseWriter, name, value string, maxAge int) {
|
|
http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: maxAge})
|
|
}
|
|
|
|
func (s *Server) browserOAuth(ctx context.Context) (*oauth2.Config, error) {
|
|
if s.auth.mode != "oidc" {
|
|
return nil, errors.New("OIDC login is not enabled")
|
|
}
|
|
redirect, err := s.browserRedirect()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
provider, err := oidc.NewProvider(ctx, s.auth.oidcIssuer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
endpoint := provider.Endpoint()
|
|
// Discovery is trusted only over TLS; never send codes to a downgraded endpoint.
|
|
for _, raw := range []string{endpoint.AuthURL, endpoint.TokenURL} {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
|
|
return nil, errors.New("OIDC endpoints must use HTTPS")
|
|
}
|
|
}
|
|
endpoint.AuthStyle = oauth2.AuthStyleInParams
|
|
return &oauth2.Config{ClientID: s.auth.oidcClientID, RedirectURL: redirect.String(), Endpoint: endpoint, Scopes: []string{"openid", "profile", "email"}}, nil
|
|
}
|
|
|
|
func safeLoginNext(next string) string {
|
|
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.ContainsAny(next, "\\\r\n") || strings.HasPrefix(next, "/auth/") {
|
|
return "/"
|
|
}
|
|
return next
|
|
}
|
|
|
|
func (s *Server) handleOIDCStart(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
cfg, err := s.browserOAuth(ctx)
|
|
if err != nil {
|
|
writeError(w, http.StatusServiceUnavailable, "could not start identity provider login")
|
|
return
|
|
}
|
|
stateBytes := make([]byte, 32)
|
|
if _, err := rand.Read(stateBytes); err != nil {
|
|
writeError(w, 500, "could not create login state")
|
|
return
|
|
}
|
|
state := base64.RawURLEncoding.EncodeToString(stateBytes)
|
|
verifier := oauth2.GenerateVerifier()
|
|
now := time.Now()
|
|
s.auth.mu.Lock()
|
|
if s.auth.oidcPending == nil {
|
|
s.auth.oidcPending = make(map[string]oidcLogin)
|
|
}
|
|
for key, login := range s.auth.oidcPending {
|
|
if now.After(login.expires) {
|
|
delete(s.auth.oidcPending, key)
|
|
}
|
|
}
|
|
if len(s.auth.oidcPending) >= 1024 {
|
|
s.auth.mu.Unlock()
|
|
writeError(w, 429, "too many pending logins")
|
|
return
|
|
}
|
|
s.auth.oidcPending[state] = oidcLogin{verifier: verifier, next: safeLoginNext(r.URL.Query().Get("next")), expires: now.Add(5 * time.Minute)}
|
|
s.auth.mu.Unlock()
|
|
s.setBrowserCookie(w, loginCookie, state, 300)
|
|
http.Redirect(w, r, cfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)), http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if !s.cookieRequestAllowed(r) {
|
|
writeError(w, 403, "invalid login origin")
|
|
return
|
|
}
|
|
var in struct {
|
|
Code string `json:"code"`
|
|
State string `json:"state"`
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&in); err != nil || in.Code == "" || in.State == "" {
|
|
writeError(w, 400, "code and state are required")
|
|
return
|
|
}
|
|
cookie, err := r.Cookie(loginCookie)
|
|
if err != nil || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(in.State)) != 1 {
|
|
writeError(w, 400, "login state check failed; start sign in again")
|
|
return
|
|
}
|
|
s.auth.mu.Lock()
|
|
login, ok := s.auth.oidcPending[in.State]
|
|
delete(s.auth.oidcPending, in.State)
|
|
s.auth.mu.Unlock()
|
|
s.setBrowserCookie(w, loginCookie, "", -1)
|
|
if !ok || time.Now().After(login.expires) {
|
|
writeError(w, 400, "login expired; start sign in again")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
cfg, err := s.browserOAuth(ctx)
|
|
if err != nil {
|
|
writeError(w, 502, "could not discover identity provider")
|
|
return
|
|
}
|
|
token, err := cfg.Exchange(ctx, in.Code, oauth2.VerifierOption(login.verifier))
|
|
if err != nil {
|
|
writeError(w, 401, "identity provider rejected login; start sign in again")
|
|
return
|
|
}
|
|
p, err := s.auth.oidcPrincipal(token.AccessToken)
|
|
if err != nil {
|
|
writeError(w, 401, "identity provider returned an invalid access token")
|
|
return
|
|
}
|
|
// Bound the session by both the configured local TTL and the signed JWT expiry.
|
|
parts := strings.Split(token.AccessToken, ".")
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
var claims struct {
|
|
Exp int64 `json:"exp"`
|
|
}
|
|
if err != nil || json.Unmarshal(payload, &claims) != nil || claims.Exp <= time.Now().Unix() {
|
|
writeError(w, 401, "access token has expired")
|
|
return
|
|
}
|
|
local, expires := s.auth.issue(p)
|
|
if local == "" {
|
|
writeError(w, 500, "could not create session")
|
|
return
|
|
}
|
|
if jwtExpiry := time.Unix(claims.Exp, 0); jwtExpiry.Before(expires) {
|
|
expires = jwtExpiry
|
|
}
|
|
s.auth.mu.Lock()
|
|
s.auth.sessions[local] = session{principal: p, expires: expires, accessToken: token.AccessToken}
|
|
s.auth.mu.Unlock()
|
|
s.setBrowserCookie(w, browserSessionCookie, local, int(time.Until(expires).Seconds()))
|
|
writeJSON(w, 200, map[string]string{"next": login.next})
|
|
}
|