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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,3 @@
|
||||
import { URL_CHAT_INDIEAUTH_BEGIN } from '../utils/constants.js'; |
||||
|
||||
export async function beginIndieAuthFlow() {} |
@ -0,0 +1,192 @@
@@ -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 @@
@@ -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