IndieAuth support (#1811)

* Able to authenticate user against IndieAuth. For #1273

* WIP server indieauth endpoint. For https://github.com/owncast/owncast/issues/1272

* Add migration to remove access tokens from user

* Add authenticated bool to user for display purposes

* Add indieauth modal and auth flair to display names. For #1273

* Validate URLs and display errors

* Renames, cleanups

* Handle relative auth endpoint paths. Add error handling for missing redirects.

* Disallow using display names in use by registered users. Closes #1810

* Verify code verifier via code challenge on callback

* Use relative path to authorization_endpoint

* Post-rebase fixes

* Use a timestamp instead of a bool for authenticated

* Propertly handle and display error in modal

* Use auth'ed timestamp to derive authenticated flag to display in chat

* don't redirect unless a URL is present

avoids redirecting to `undefined` if there was an error

* improve error message if owncast server URL isn't set

* fix IndieAuth PKCE implementation

use SHA256 instead of SHA1, generates a longer code verifier (must be 43-128 chars long), fixes URL-safe SHA256 encoding

* return real profile data for IndieAuth response

* check the code verifier in the IndieAuth server

* Linting

* Add new chat settings modal anad split up indieauth ui

* Remove logging error

* Update the IndieAuth modal UI. For #1273

* Add IndieAuth repsonse error checking

* Disable IndieAuth client if server URL is not set.

* Add explicit error messages for specific error types

* Fix bad logic

* Return OAuth-keyed error responses for indieauth server

* Display IndieAuth error in plain text with link to return to main page

* Remove redundant check

* Add additional detail to error

* Hide IndieAuth details behind disclosure details

* Break out migration into two steps because some people have been runing dev in production

* Add auth option to user dropdown

Co-authored-by: Aaron Parecki <aaron@parecki.com>
This commit is contained in:
Gabe Kangas
2022-04-21 14:55:26 -07:00
committed by GitHub
parent b86537fa91
commit b835de2dc4
47 changed files with 1844 additions and 274 deletions

112
auth/indieauth/client.go Normal file
View File

@@ -0,0 +1,112 @@
package indieauth
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/owncast/owncast/core/data"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
var pendingAuthRequests = make(map[string]*Request)
// StartAuthFlow will begin the IndieAuth flow by generating an auth request.
func StartAuthFlow(authHost, userID, accessToken, displayName string) (*url.URL, error) {
serverURL := data.GetServerURL()
if serverURL == "" {
return nil, errors.New("Owncast server URL must be set when using auth")
}
r, err := createAuthRequest(authHost, userID, displayName, accessToken, serverURL)
if err != nil {
return nil, errors.Wrap(err, "unable to generate IndieAuth request")
}
pendingAuthRequests[r.State] = r
return r.Redirect, nil
}
// HandleCallbackCode will handle the callback from the IndieAuth server
// to continue the next step of the auth flow.
func HandleCallbackCode(code, state string) (*Request, *Response, error) {
request, exists := pendingAuthRequests[state]
if !exists {
return nil, nil, errors.New("no auth requests pending")
}
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("client_id", request.ClientID)
data.Set("redirect_uri", request.Callback.String())
data.Set("code_verifier", request.CodeVerifier)
client := &http.Client{}
r, err := http.NewRequest("POST", request.Endpoint.String(), strings.NewReader(data.Encode())) // URL-encoded payload
if err != nil {
return nil, nil, err
}
r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
r.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
res, err := client.Do(r)
if err != nil {
return nil, nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, nil, err
}
var response Response
if err := json.Unmarshal(body, &response); err != nil {
return nil, nil, errors.Wrap(err, "unable to parse IndieAuth response")
}
if response.Error != "" || response.ErrorDescription != "" {
errorText := makeIndieAuthClientErrorText(response.Error)
log.Debugln("IndieAuth error:", response.Error, response.ErrorDescription)
return nil, nil, fmt.Errorf("IndieAuth error: %s - %s", errorText, response.ErrorDescription)
}
// In case this IndieAuth server does not use OAuth error keys or has internal
// issues resulting in unstructured errors.
if res.StatusCode < 200 || res.StatusCode > 299 {
log.Debugln("IndieAuth error. status code:", res.StatusCode, "body:", string(body))
return nil, nil, errors.New("there was an error authenticating against IndieAuth server")
}
// Trim any trailing slash so we can accurately compare the two "me" values
meResponseVerifier := strings.TrimRight(response.Me, "/")
meRequestVerifier := strings.TrimRight(request.Me.String(), "/")
// What we sent and what we got back must match
if meRequestVerifier != meResponseVerifier {
return nil, nil, errors.New("indieauth response does not match the initial anticipated auth destination")
}
return request, &response, nil
}
// Error value should be from this list:
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
func makeIndieAuthClientErrorText(err string) string {
switch err {
case "invalid_request", "invalid_client":
return "The authentication request was invalid. Please report this to the Owncast project."
case "invalid_grant", "unauthorized_client":
return "This authorization request is unauthorized."
case "unsupported_grant_type":
return "The authorization grant type is not supported by the authorization server."
default:
return err
}
}

120
auth/indieauth/helpers.go Normal file
View File

@@ -0,0 +1,120 @@
package indieauth
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/andybalholm/cascadia"
"github.com/pkg/errors"
"golang.org/x/net/html"
)
func createAuthRequest(authDestination, userID, displayName, accessToken, baseServer string) (*Request, error) {
authURL, err := url.Parse(authDestination)
if err != nil {
return nil, errors.Wrap(err, "unable to parse IndieAuth destination")
}
authEndpointURL, err := getAuthEndpointFromURL(authURL.String())
if err != nil {
return nil, errors.Wrap(err, "unable to get IndieAuth endpoint from destination URL")
}
baseServerURL, err := url.Parse(baseServer)
if err != nil {
return nil, errors.Wrap(err, "unable to parse local owncast base server URL")
}
callbackURL := *baseServerURL
callbackURL.Path = "/api/auth/indieauth/callback"
codeVerifier := randString(50)
codeChallenge := createCodeChallenge(codeVerifier)
state := randString(20)
responseType := "code"
clientID := baseServerURL.String() // Our local URL
codeChallengeMethod := "S256"
redirect := *authEndpointURL
q := authURL.Query()
q.Add("response_type", responseType)
q.Add("client_id", clientID)
q.Add("state", state)
q.Add("code_challenge_method", codeChallengeMethod)
q.Add("code_challenge", codeChallenge)
q.Add("me", authURL.String())
q.Add("redirect_uri", callbackURL.String())
redirect.RawQuery = q.Encode()
return &Request{
Me: authURL,
UserID: userID,
DisplayName: displayName,
CurrentAccessToken: accessToken,
Endpoint: authEndpointURL,
ClientID: baseServer,
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
State: state,
Redirect: &redirect,
Callback: &callbackURL,
}, nil
}
func getAuthEndpointFromURL(urlstring string) (*url.URL, error) {
htmlDocScrapeURL, err := url.Parse(urlstring)
if err != nil {
return nil, errors.Wrap(err, "unable to parse URL")
}
r, err := http.Get(htmlDocScrapeURL.String()) // nolint:gosec
if err != nil {
return nil, err
}
defer r.Body.Close()
scrapedHTMLDocument, err := html.Parse(r.Body)
if err != nil {
return nil, errors.Wrap(err, "unable to parse html at remote auth host")
}
authorizationEndpointTag := cascadia.MustCompile("link[rel=authorization_endpoint]").MatchAll(scrapedHTMLDocument)
if len(authorizationEndpointTag) == 0 {
return nil, fmt.Errorf("url does not support indieauth")
}
for _, attr := range authorizationEndpointTag[len(authorizationEndpointTag)-1].Attr {
if attr.Key == "href" {
u, err := url.Parse(attr.Val)
if err != nil {
return nil, errors.Wrap(err, "unable to parse authorization endpoint")
}
// If it is a relative URL we an fill in the missing components
// by using the original URL we scraped, since it is the same host.
if u.Scheme == "" {
u.Scheme = htmlDocScrapeURL.Scheme
}
if u.Host == "" {
u.Host = htmlDocScrapeURL.Host
}
return u, nil
}
}
return nil, fmt.Errorf("unable to find href value for authorization_endpoint")
}
func createCodeChallenge(codeVerifier string) string {
sha256hash := sha256.Sum256([]byte(codeVerifier))
encodedHashedCode := strings.TrimRight(base64.URLEncoding.EncodeToString(sha256hash[:]), "=")
return encodedHashedCode
}

34
auth/indieauth/random.go Normal file
View File

@@ -0,0 +1,34 @@
package indieauth
import (
"math/rand"
"time"
"unsafe"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
var src = rand.NewSource(time.Now().UnixNano())
func randString(n int) string {
b := make([]byte, n)
// A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = src.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return *(*string)(unsafe.Pointer(&b)) // nolint:gosec
}

18
auth/indieauth/request.go Normal file
View File

@@ -0,0 +1,18 @@
package indieauth
import "net/url"
// Request represents a single in-flight IndieAuth request.
type Request struct {
UserID string
DisplayName string
CurrentAccessToken string
Endpoint *url.URL
Redirect *url.URL // Outbound redirect URL to continue auth flow
Callback *url.URL // Inbound URL to get auth flow results
ClientID string
CodeVerifier string
CodeChallenge string
State string
Me *url.URL
}

View File

@@ -0,0 +1,18 @@
package indieauth
// Profile represents optional profile data that is returned
// when completing the IndieAuth flow.
type Profile struct {
Name string `json:"name"`
URL string `json:"url"`
Photo string `json:"photo"`
}
// Response the response returned when completing
// the IndieAuth flow.
type Response struct {
Me string `json:"me,omitempty"`
Profile Profile `json:"profile,omitempty"`
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}

92
auth/indieauth/server.go Normal file
View File

@@ -0,0 +1,92 @@
package indieauth
import (
"fmt"
"github.com/owncast/owncast/core/data"
"github.com/pkg/errors"
"github.com/teris-io/shortid"
)
// ServerAuthRequest is n inbound request to authenticate against
// this Owncast instance.
type ServerAuthRequest struct {
ClientID string
RedirectURI string
CodeChallenge string
State string
Me string
Code string
}
// ServerProfile represents basic user-provided data about this Owncast instance.
type ServerProfile struct {
Name string `json:"name"`
URL string `json:"url"`
Photo string `json:"photo"`
}
// ServerProfileResponse is returned when an auth flow requests the final
// confirmation of the IndieAuth flow.
type ServerProfileResponse struct {
Me string `json:"me,omitempty"`
Profile ServerProfile `json:"profile,omitempty"`
// Error keys need to match the OAuth spec.
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
var pendingServerAuthRequests = map[string]ServerAuthRequest{}
// StartServerAuth will handle the authentication for the admin user of this
// Owncast server. Initiated via a GET of the auth endpoint.
// https://indieweb.org/authorization-endpoint
func StartServerAuth(clientID, redirectURI, codeChallenge, state, me string) (*ServerAuthRequest, error) {
code := shortid.MustGenerate()
r := ServerAuthRequest{
ClientID: clientID,
RedirectURI: redirectURI,
CodeChallenge: codeChallenge,
State: state,
Me: me,
Code: code,
}
pendingServerAuthRequests[code] = r
return &r, nil
}
// CompleteServerAuth will verify that the values provided in the final step
// of the IndieAuth flow are correct, and return some basic profile info.
func CompleteServerAuth(code, redirectURI, clientID string, codeVerifier string) (*ServerProfileResponse, error) {
request, pending := pendingServerAuthRequests[code]
if !pending {
return nil, errors.New("no pending authentication request")
}
if request.RedirectURI != redirectURI {
return nil, errors.New("redirect URI does not match")
}
if request.ClientID != clientID {
return nil, errors.New("client ID does not match")
}
codeChallengeFromRequest := createCodeChallenge(codeVerifier)
if request.CodeChallenge != codeChallengeFromRequest {
return nil, errors.New("code verifier is incorrect")
}
response := ServerProfileResponse{
Me: data.GetServerURL(),
Profile: ServerProfile{
Name: data.GetServerName(),
URL: data.GetServerURL(),
Photo: fmt.Sprintf("%s/%s", data.GetServerURL(), data.GetLogoPath()),
},
}
return &response, nil
}