1 parent
7fa48bcdf9
commit
a3d1614dca
15 files changed
+704
-123
No files matched your search
@@ -0,0 +1,193 @@
|
||||
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})
|
||||
}
|
||||
Reference in new issue
Block a user