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:
11
auth/auth.go
Normal file
11
auth/auth.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package auth
|
||||
|
||||
// Type represents a form of authentication.
|
||||
type Type string
|
||||
|
||||
// The different auth types we support.
|
||||
// Currently only IndieAuth.
|
||||
const (
|
||||
// IndieAuth https://indieauth.spec.indieweb.org/.
|
||||
IndieAuth Type = "indieauth"
|
||||
)
|
||||
112
auth/indieauth/client.go
Normal file
112
auth/indieauth/client.go
Normal 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
120
auth/indieauth/helpers.go
Normal 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
34
auth/indieauth/random.go
Normal 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
18
auth/indieauth/request.go
Normal 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
|
||||
}
|
||||
18
auth/indieauth/response.go
Normal file
18
auth/indieauth/response.go
Normal 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
92
auth/indieauth/server.go
Normal 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
|
||||
}
|
||||
77
auth/persistence.go
Normal file
77
auth/persistence.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/core/user"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/owncast/owncast/db"
|
||||
)
|
||||
|
||||
var _datastore *data.Datastore
|
||||
|
||||
// Setup will initialize auth persistence.
|
||||
func Setup(db *data.Datastore) {
|
||||
_datastore = db
|
||||
|
||||
createTableSQL := `CREATE TABLE IF NOT EXISTS auth (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);CREATE INDEX auth_token ON auth (token);`
|
||||
|
||||
stmt, err := db.DB.Prepare(createTableSQL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
_, err = stmt.Exec()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
|
||||
// AddAuth will add an external authentication token and type for a user.
|
||||
func AddAuth(userID, authToken string, authType Type) error {
|
||||
return _datastore.GetQueries().AddAuthForUser(context.Background(), db.AddAuthForUserParams{
|
||||
UserID: userID,
|
||||
Token: authToken,
|
||||
Type: string(authType),
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserByAuth will return an existing user given auth details if a user
|
||||
// has previously authenticated with that method.
|
||||
func GetUserByAuth(authToken string, authType Type) *user.User {
|
||||
u, err := _datastore.GetQueries().GetUserByAuth(context.Background(), db.GetUserByAuthParams{
|
||||
Token: authToken,
|
||||
Type: string(authType),
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var scopes []string
|
||||
if u.Scopes.Valid {
|
||||
scopes = strings.Split(u.Scopes.String, ",")
|
||||
}
|
||||
|
||||
return &user.User{
|
||||
ID: u.ID,
|
||||
DisplayName: u.DisplayName,
|
||||
DisplayColor: int(u.DisplayColor),
|
||||
CreatedAt: u.CreatedAt.Time,
|
||||
DisabledAt: &u.DisabledAt.Time,
|
||||
PreviousNames: strings.Split(u.PreviousNames.String, ","),
|
||||
NameChangedAt: &u.NamechangedAt.Time,
|
||||
AuthenticatedAt: &u.AuthenticatedAt.Time,
|
||||
Scopes: scopes,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user