// // tank // // Import and Playlist Daemon for autoradio project // // // Copyright (C) 2017-2019 Christian Pointner <equinox@helsinki.at> // // This file is part of tank. // // tank is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // any later version. // // tank is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with tank. If not, see <http://www.gnu.org/licenses/>. // package auth import ( "context" "encoding/json" "errors" "net/http" "time" oidc "github.com/coreos/go-oidc" "golang.org/x/oauth2" ) const ( defaultLoginTimeout = 5 * time.Minute ) type OIDCSession struct { backend *OIDCBackend nonce string tokenSource oauth2.TokenSource } func (os *OIDCSession) refresh(ctx context.Context, in *Session) (*Session, error) { userInfo, err := os.backend.provider.UserInfo(ctx, os.tokenSource) if err != nil { return nil, errors.New("fetching OIDC UserInfo failed: " + err.Error()) } out := in.emptyCopy() if err := userInfo.Claims(out); err != nil { return nil, errors.New("parsing OIDC UserInfo failed: " + err.Error()) } if err = auth.sessions.update(in.ID(), out); err != nil { return nil, errors.New("updating session failed: " + err.Error()) } return out, nil } // TODO this needs more testing!! func refreshSession(s *Session) { ticker := time.NewTicker(5 * time.Minute) // TODO: hardcoded value defer ticker.Stop() errCnt := 0 for { // TODO: also subscribe to session updates... select { case <-s.ctx.Done(): return case <-ticker.C: ctx, cancel := context.WithTimeout(s.ctx, time.Minute) // TODO: hardcoded value newS, err := s.oidc.refresh(ctx, s) cancel() if err == nil { errCnt++ if errCnt > 3 { // TODO: hardcoded value s.setState(SessionStateLoggedOut) return } } else { s = newS } } } } func loginTimeout(s *Session) { if s.updateState(SessionStateNew, SessionStateLoginTimeout) { return } if s.updateState(SessionStateLoginStarted, SessionStateLoginTimeout) { return } if s.updateState(SessionStateLoginFinalizing, SessionStateLoginTimeout) { return } } // This is only safe when session is logged in! func (s *OIDCSession) MarshalJSON() ([]byte, error) { t, err := s.tokenSource.Token() if err != nil { return nil, err } return json.Marshal(struct { Token *oauth2.Token `json:"token,omitempty"` }{ Token: t, }) } type OIDCBackend struct { loginTimeout time.Duration issuerURL string provider *oidc.Provider verifier *oidc.IDTokenVerifier oauth2Config oauth2.Config } func NewOIDCBackend(cfg *OIDCConfig) (b *OIDCBackend, err error) { // TODO: make ctx a parameter? ctx := context.Background() b = &OIDCBackend{issuerURL: cfg.IssuerURL, loginTimeout: cfg.LoginTimeout} if b.loginTimeout <= 0 { b.loginTimeout = defaultLoginTimeout } if b.provider, err = oidc.NewProvider(ctx, cfg.IssuerURL); err != nil { return } oidcConfig := &oidc.Config{ ClientID: cfg.ClientID, } b.verifier = b.provider.Verifier(oidcConfig) b.oauth2Config = oauth2.Config{ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Endpoint: b.provider.Endpoint(), RedirectURL: cfg.CallbackURL, Scopes: []string{oidc.ScopeOpenID, "username", "aura_shows"}, } return } func (b *OIDCBackend) String() string { return "OpenID Connect using Identity Provider: " + b.issuerURL } func (b *OIDCBackend) NewOIDCSession(ctx context.Context, arguments json.RawMessage) (s *Session, err error) { os := &OIDCSession{backend: b} if os.nonce, err = generateRandomString(16); err != nil { return } // TODO: set session state to login-failed on any error... if s, err = auth.sessions.new(); err != nil { return nil, err } s.oidc = os // TODO: this is probably fine but rather ugly... if arguments != nil { var oauth2Token oauth2.Token if err = json.Unmarshal(arguments, &oauth2Token); err != nil { return nil, errors.New("failed to parse Oauth2 Token: " + err.Error()) } os.tokenSource = b.oauth2Config.TokenSource(s.ctx, &oauth2Token) if s, err = os.refresh(ctx, s); err != nil { return } s.setState(SessionStateLoggedIn) go refreshSession(s) } else { time.AfterFunc(b.loginTimeout, func() { loginTimeout(s) }) } return } func (b *OIDCBackend) LoginHandler() http.Handler { return &oidcLoginHandler{backend: b} } func (b *OIDCBackend) CallbackHandler() http.Handler { return &oidcCallbackHandler{backend: b} } type oidcLoginHandler struct { backend *OIDCBackend } func (h *oidcLoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { s := auth.sessions.get(r.URL.Query().Get("session-id")) if s == nil { sendHTTPInvalidSessionResponse(w) return } if s.oidc == nil { sendHTTPErrorResponse(w, http.StatusConflict, "this is not an OIDC session") return } if !s.updateState(SessionStateNew, SessionStateLoginStarted) { sendHTTPErrorResponse(w, http.StatusConflict, "this session is already logged in or in an invalid state") return } http.Redirect(w, r, h.backend.oauth2Config.AuthCodeURL(s.ID(), oidc.Nonce(s.oidc.nonce)), http.StatusFound) } type oidcCallbackHandler struct { backend *OIDCBackend } func (h *oidcCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { s := auth.sessions.get(r.URL.Query().Get("state")) if s == nil { sendHTTPInvalidSessionResponse(w) return } if s.oidc == nil { sendHTTPErrorResponse(w, http.StatusConflict, "this is not an OIDC session") return } // TODO: handle login error (query parameter: error, error_description) if !s.updateState(SessionStateLoginStarted, SessionStateLoginFinalizing) { sendHTTPErrorResponse(w, http.StatusConflict, "this session is already logged in or in an invalid state") return } oauth2Token, err := h.backend.oauth2Config.Exchange(r.Context(), r.URL.Query().Get("code")) if err != nil { s.setState(SessionStateLoginFailed) sendHTTPErrorResponse(w, http.StatusBadRequest, "OAuth2 token exchange failed: "+err.Error()) return } rawIDToken, ok := oauth2Token.Extra("id_token").(string) if !ok { s.setState(SessionStateLoginFailed) sendHTTPErrorResponse(w, http.StatusBadRequest, "OIDC verification failed: no id_token field in oauth2 token.") return } // Verify the ID Token signature and nonce. idToken, err := h.backend.verifier.Verify(r.Context(), rawIDToken) if err != nil { s.setState(SessionStateLoginFailed) sendHTTPErrorResponse(w, http.StatusInternalServerError, "OAuth2 ID token verification failed: "+err.Error()) return } if idToken.Nonce != s.oidc.nonce { s.setState(SessionStateLoginFailed) sendHTTPErrorResponse(w, http.StatusInternalServerError, "OAuth2 ID token verification failed: invalid nonce") return } // Populate a new session with data from UserInfo endpoint. s.oidc.tokenSource = h.backend.oauth2Config.TokenSource(s.ctx, oauth2Token) if s, err = s.oidc.refresh(r.Context(), s); err != nil { s.setState(SessionStateLoginFailed) sendHTTPErrorResponse(w, http.StatusInternalServerError, err.Error()) return } if !s.updateState(SessionStateLoginFinalizing, SessionStateLoggedIn) { sendHTTPErrorResponse(w, http.StatusGone, "session login timeout") return } sendHTTPResponse(w, http.StatusOK, "You are now logged in as: "+s.Username) }