Browse Source
* 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>pull/1855/head
47 changed files with 1855 additions and 285 deletions
@ -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" |
||||||
|
) |
||||||
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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
|
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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"` |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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, |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,103 @@ |
|||||||
|
package indieauth |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/json" |
||||||
|
"fmt" |
||||||
|
"io" |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"github.com/owncast/owncast/auth" |
||||||
|
ia "github.com/owncast/owncast/auth/indieauth" |
||||||
|
"github.com/owncast/owncast/controllers" |
||||||
|
"github.com/owncast/owncast/core/chat" |
||||||
|
"github.com/owncast/owncast/core/user" |
||||||
|
log "github.com/sirupsen/logrus" |
||||||
|
) |
||||||
|
|
||||||
|
// StartAuthFlow will begin the IndieAuth flow for the current user.
|
||||||
|
func StartAuthFlow(u user.User, w http.ResponseWriter, r *http.Request) { |
||||||
|
type request struct { |
||||||
|
AuthHost string `json:"authHost"` |
||||||
|
} |
||||||
|
|
||||||
|
type response struct { |
||||||
|
Redirect string `json:"redirect"` |
||||||
|
} |
||||||
|
|
||||||
|
var authRequest request |
||||||
|
p, err := io.ReadAll(r.Body) |
||||||
|
if err != nil { |
||||||
|
controllers.WriteSimpleResponse(w, false, err.Error()) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if err := json.Unmarshal(p, &authRequest); err != nil { |
||||||
|
controllers.WriteSimpleResponse(w, false, err.Error()) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
accessToken := r.URL.Query().Get("accessToken") |
||||||
|
|
||||||
|
redirectURL, err := ia.StartAuthFlow(authRequest.AuthHost, u.ID, accessToken, u.DisplayName) |
||||||
|
if err != nil { |
||||||
|
controllers.WriteSimpleResponse(w, false, err.Error()) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
redirectResponse := response{ |
||||||
|
Redirect: redirectURL.String(), |
||||||
|
} |
||||||
|
controllers.WriteResponse(w, redirectResponse) |
||||||
|
} |
||||||
|
|
||||||
|
// HandleRedirect will handle the redirect from an IndieAuth server to
|
||||||
|
// continue the auth flow.
|
||||||
|
func HandleRedirect(w http.ResponseWriter, r *http.Request) { |
||||||
|
state := r.URL.Query().Get("state") |
||||||
|
code := r.URL.Query().Get("code") |
||||||
|
request, response, err := ia.HandleCallbackCode(code, state) |
||||||
|
if err != nil { |
||||||
|
log.Debugln(err) |
||||||
|
msg := fmt.Sprintf("Unable to complete authentication. <a href=\"/\">Go back.</a><hr/> %s", err.Error()) |
||||||
|
_ = controllers.WriteString(w, msg, http.StatusBadRequest) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Check if a user with this auth already exists, if so, log them in.
|
||||||
|
if u := auth.GetUserByAuth(response.Me, auth.IndieAuth); u != nil { |
||||||
|
// Handle existing auth.
|
||||||
|
log.Debugln("user with provided indieauth already exists, logging them in") |
||||||
|
|
||||||
|
// Update the current user's access token to point to the existing user id.
|
||||||
|
accessToken := request.CurrentAccessToken |
||||||
|
userID := u.ID |
||||||
|
if err := user.SetAccessTokenToOwner(accessToken, userID); err != nil { |
||||||
|
controllers.WriteSimpleResponse(w, false, err.Error()) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
loginMessage := fmt.Sprintf("**%s** is now authenticated as **%s**", request.DisplayName, u.DisplayName) |
||||||
|
if err := chat.SendSystemAction(loginMessage, true); err != nil { |
||||||
|
log.Errorln(err) |
||||||
|
} |
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect) |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Otherwise, save this as new auth.
|
||||||
|
log.Debug("indieauth token does not already exist, saving it as a new one for the current user") |
||||||
|
if err := auth.AddAuth(request.UserID, response.Me, auth.IndieAuth); err != nil { |
||||||
|
controllers.WriteSimpleResponse(w, false, err.Error()) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Update the current user's authenticated flag so we can show it in
|
||||||
|
// the chat UI.
|
||||||
|
if err := user.SetUserAsAuthenticated(request.UserID); err != nil { |
||||||
|
log.Errorln(err) |
||||||
|
} |
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect) |
||||||
|
} |
||||||
@ -0,0 +1,80 @@ |
|||||||
|
package indieauth |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
"net/url" |
||||||
|
|
||||||
|
ia "github.com/owncast/owncast/auth/indieauth" |
||||||
|
"github.com/owncast/owncast/controllers" |
||||||
|
) |
||||||
|
|
||||||
|
// HandleAuthEndpoint will handle the IndieAuth auth endpoint.
|
||||||
|
func HandleAuthEndpoint(w http.ResponseWriter, r *http.Request) { |
||||||
|
if r.Method == http.MethodGet { |
||||||
|
// Require the GET request for IndieAuth to be behind admin login.
|
||||||
|
handleAuthEndpointGet(w, r) |
||||||
|
} else if r.Method == http.MethodPost { |
||||||
|
handleAuthEndpointPost(w, r) |
||||||
|
} else { |
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed) |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func handleAuthEndpointGet(w http.ResponseWriter, r *http.Request) { |
||||||
|
clientID := r.URL.Query().Get("client_id") |
||||||
|
redirectURI := r.URL.Query().Get("redirect_uri") |
||||||
|
codeChallenge := r.URL.Query().Get("code_challenge") |
||||||
|
state := r.URL.Query().Get("state") |
||||||
|
me := r.URL.Query().Get("me") |
||||||
|
|
||||||
|
request, err := ia.StartServerAuth(clientID, redirectURI, codeChallenge, state, me) |
||||||
|
if err != nil { |
||||||
|
// Return a human readable, HTML page as an error. JSON is no use here.
|
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// Redirect the client browser with the values we generated to continue
|
||||||
|
// the IndieAuth flow.
|
||||||
|
// If the URL is invalid then return with specific "invalid_request" error.
|
||||||
|
u, err := url.Parse(redirectURI) |
||||||
|
if err != nil { |
||||||
|
controllers.WriteResponse(w, ia.Response{ |
||||||
|
Error: "invalid_request", |
||||||
|
ErrorDescription: err.Error(), |
||||||
|
}) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
redirectParams := u.Query() |
||||||
|
redirectParams.Set("code", request.Code) |
||||||
|
redirectParams.Set("state", request.State) |
||||||
|
u.RawQuery = redirectParams.Encode() |
||||||
|
|
||||||
|
http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect) |
||||||
|
} |
||||||
|
|
||||||
|
func handleAuthEndpointPost(w http.ResponseWriter, r *http.Request) { |
||||||
|
if err := r.ParseForm(); err != nil { |
||||||
|
controllers.WriteSimpleResponse(w, false, err.Error()) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
code := r.PostForm.Get("code") |
||||||
|
redirectURI := r.PostForm.Get("redirect_uri") |
||||||
|
clientID := r.PostForm.Get("client_id") |
||||||
|
codeVerifier := r.PostForm.Get("code_verifier") |
||||||
|
|
||||||
|
// If the server auth flow cannot be completed then return with specific
|
||||||
|
// "invalid_client" error.
|
||||||
|
response, err := ia.CompleteServerAuth(code, redirectURI, clientID, codeVerifier) |
||||||
|
if err != nil { |
||||||
|
controllers.WriteResponse(w, ia.Response{ |
||||||
|
Error: "invalid_client", |
||||||
|
ErrorDescription: err.Error(), |
||||||
|
}) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
controllers.WriteResponse(w, response) |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 6.5 KiB |
@ -0,0 +1,3 @@ |
|||||||
|
import { URL_CHAT_INDIEAUTH_BEGIN } from '../utils/constants.js'; |
||||||
|
|
||||||
|
export async function beginIndieAuthFlow() {} |
||||||
@ -0,0 +1,192 @@ |
|||||||
|
import { h, Component } from '/js/web_modules/preact.js'; |
||||||
|
import htm from '/js/web_modules/htm.js'; |
||||||
|
const html = htm.bind(h); |
||||||
|
|
||||||
|
export default class IndieAuthForm extends Component { |
||||||
|
constructor(props) { |
||||||
|
super(props); |
||||||
|
|
||||||
|
this.submitButtonPressed = this.submitButtonPressed.bind(this); |
||||||
|
|
||||||
|
this.state = { |
||||||
|
errorMessage: null, |
||||||
|
loading: false, |
||||||
|
valid: false, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
async submitButtonPressed() { |
||||||
|
const { accessToken, authenticated } = this.props; |
||||||
|
const { host, valid } = this.state; |
||||||
|
|
||||||
|
if (!valid) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const url = `/api/auth/indieauth?accessToken=${accessToken}`; |
||||||
|
const data = { authHost: host }; |
||||||
|
|
||||||
|
this.setState({ loading: true }); |
||||||
|
|
||||||
|
try { |
||||||
|
const rawResponse = await fetch(url, { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
Accept: 'application/json', |
||||||
|
'Content-Type': 'application/json', |
||||||
|
}, |
||||||
|
body: JSON.stringify(data), |
||||||
|
}); |
||||||
|
|
||||||
|
const content = await rawResponse.json(); |
||||||
|
if (content.message) { |
||||||
|
this.setState({ errorMessage: content.message, loading: false }); |
||||||
|
return; |
||||||
|
} else if (!content.redirect) { |
||||||
|
this.setState({ |
||||||
|
errorMessage: 'Auth provider did not return a redirect URL.', |
||||||
|
loading: false, |
||||||
|
}); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (content.redirect) { |
||||||
|
const redirect = content.redirect; |
||||||
|
window.location = redirect; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.error(e); |
||||||
|
this.setState({ errorMessage: e, loading: false }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onInput = (e) => { |
||||||
|
const { value } = e.target; |
||||||
|
let valid = validateURL(value); |
||||||
|
this.setState({ host: value, valid }); |
||||||
|
}; |
||||||
|
|
||||||
|
render() { |
||||||
|
const { errorMessage, loading, host, valid } = this.state; |
||||||
|
const { authenticated } = this.props; |
||||||
|
const buttonState = valid ? '' : 'cursor-not-allowed opacity-50'; |
||||||
|
const loaderStyle = loading ? 'flex' : 'none'; |
||||||
|
|
||||||
|
const message = !authenticated |
||||||
|
? `While you can chat completely anonymously you can also add
|
||||||
|
authentication so you can rejoin with the same chat persona from any |
||||||
|
device or browser.` |
||||||
|
: html`<span
|
||||||
|
><b>You are already authenticated</b>. However, you can add other |
||||||
|
external sites or log in as a different user.</span |
||||||
|
>`;
|
||||||
|
|
||||||
|
let errorMessageText = errorMessage; |
||||||
|
if (!!errorMessageText) { |
||||||
|
if (errorMessageText.includes('url does not support indieauth')) { |
||||||
|
errorMessageText = |
||||||
|
'The provided URL is either invalid or does not support IndieAuth.'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const error = errorMessage |
||||||
|
? html` <div
|
||||||
|
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" |
||||||
|
role="alert" |
||||||
|
> |
||||||
|
<div class="font-bold mb-2">There was an error.</div> |
||||||
|
<div class="block mt-2"> |
||||||
|
<div>${errorMessageText}</div> |
||||||
|
</div> |
||||||
|
</div>` |
||||||
|
: null; |
||||||
|
|
||||||
|
return html` <div>
|
||||||
|
<p class="text-gray-700">${message}</p> |
||||||
|
|
||||||
|
<p>${error}</p> |
||||||
|
|
||||||
|
<div class="mb34"> |
||||||
|
<label |
||||||
|
class="block text-gray-700 text-sm font-semibold mt-6" |
||||||
|
for="username" |
||||||
|
> |
||||||
|
Your domain |
||||||
|
</label> |
||||||
|
<input |
||||||
|
onInput=${this.onInput} |
||||||
|
type="url" |
||||||
|
value=${host} |
||||||
|
class="border bg-white rounded w-full py-2 px-3 mb-2 mt-2 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline" |
||||||
|
id="username" |
||||||
|
type="text" |
||||||
|
placeholder="https://yoursite.com" |
||||||
|
/> |
||||||
|
<button |
||||||
|
class="bg-indigo-500 hover:bg-indigo-600 text-white font-bold py-2 mt-6 px-4 rounded focus:outline-none focus:shadow-outline ${buttonState}" |
||||||
|
type="button" |
||||||
|
onClick=${this.submitButtonPressed} |
||||||
|
> |
||||||
|
Authenticate with your domain |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<p class="mt-4"> |
||||||
|
<details> |
||||||
|
<summary class="cursor-pointer"> |
||||||
|
Learn more about <span class="text-blue-500">IndieAuth</span> |
||||||
|
</summary> |
||||||
|
<div class="inline"> |
||||||
|
<p class="mt-4"> |
||||||
|
IndieAuth allows for a completely independent and decentralized |
||||||
|
way of identifying yourself using your own domain. |
||||||
|
</p> |
||||||
|
|
||||||
|
<p class="mt-4"> |
||||||
|
If you run an Owncast instance, you can use that domain here. |
||||||
|
Otherwise, ${' '} |
||||||
|
<a class="underline" href="https://indieauth.net/#providers" |
||||||
|
>learn more about how you can support IndieAuth</a |
||||||
|
>. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</details> |
||||||
|
</p> |
||||||
|
|
||||||
|
<p class="mt-4"> |
||||||
|
<b>Note:</b> This is for authentication purposes only, and no personal |
||||||
|
information will be accessed or stored. |
||||||
|
</p> |
||||||
|
|
||||||
|
<div |
||||||
|
id="follow-loading-spinner-container" |
||||||
|
style="display: ${loaderStyle}" |
||||||
|
> |
||||||
|
<img id="follow-loading-spinner" src="/img/loading.gif" /> |
||||||
|
<p class="text-gray-700 text-lg">Authenticating.</p> |
||||||
|
<p class="text-gray-600 text-lg">Please wait...</p> |
||||||
|
</div> |
||||||
|
</div>`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function validateURL(url) { |
||||||
|
if (!url) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const u = new URL(url); |
||||||
|
if (!u) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
if (u.protocol !== 'https:') { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
@ -0,0 +1,44 @@ |
|||||||
|
import { h, Component } from '/js/web_modules/preact.js'; |
||||||
|
import htm from '/js/web_modules/htm.js'; |
||||||
|
import TabBar from './tab-bar.js'; |
||||||
|
import IndieAuthForm from './auth-indieauth.js'; |
||||||
|
|
||||||
|
const html = htm.bind(h); |
||||||
|
|
||||||
|
export default class ChatSettingsModal extends Component { |
||||||
|
render() { |
||||||
|
const { |
||||||
|
accessToken, |
||||||
|
authenticated, |
||||||
|
username, |
||||||
|
onUsernameChange, |
||||||
|
indieAuthEnabled, |
||||||
|
} = this.props; |
||||||
|
|
||||||
|
const TAB_CONTENT = [ |
||||||
|
{ |
||||||
|
label: html`<span style=${{ display: 'flex', alignItems: 'center' }}
|
||||||
|
><img |
||||||
|
style=${{ |
||||||
|
display: 'inline', |
||||||
|
height: '0.8em', |
||||||
|
marginRight: '5px', |
||||||
|
}} |
||||||
|
src="/img/indieauth.png" |
||||||
|
/> |
||||||
|
IndieAuth</span |
||||||
|
>`,
|
||||||
|
content: html`<${IndieAuthForm}}
|
||||||
|
accessToken=${accessToken} |
||||||
|
authenticated=${authenticated} |
||||||
|
/>`, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
return html` |
||||||
|
<div class="bg-gray-100 bg-center bg-no-repeat p-5"> |
||||||
|
<${TabBar} tabs=${TAB_CONTENT} ariaLabel="Chat settings" /> |
||||||
|
</div> |
||||||
|
`;
|
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue