diff --git a/database_migrations/000012_oauth_login_status_target_uri.down.sql b/database_migrations/000012_oauth_login_status_target_uri.down.sql new file mode 100644 index 0000000..8dd78df --- /dev/null +++ b/database_migrations/000012_oauth_login_status_target_uri.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE oauth_login_states + DROP COLUMN target_uri; + diff --git a/database_migrations/000012_oauth_login_status_target_uri.up.sql b/database_migrations/000012_oauth_login_status_target_uri.up.sql new file mode 100644 index 0000000..77603d8 --- /dev/null +++ b/database_migrations/000012_oauth_login_status_target_uri.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE oauth_login_states + ADD COLUMN target_uri TEXT NOT NULL; diff --git a/internal/domains/authentication/auth.go b/internal/domains/authentication/auth.go index 8982821..f9d7706 100644 --- a/internal/domains/authentication/auth.go +++ b/internal/domains/authentication/auth.go @@ -100,27 +100,28 @@ func (a *Authenticator) RunBackgroundCleanup(ctx context.Context) error { } // Exchange exchanges an auth code for an access token. -func (a *Authenticator) Exchange(ctx context.Context, state, code string) (accessToken string, expiration time.Time, err error) { +func (a *Authenticator) Exchange(ctx context.Context, state, code string) (accessToken, targetURI string, expiration time.Time, err error) { // validate state - if exp, err := a.GetStateExpiration(ctx, state); errors.Is(err, consts.ErrNotFound) { - return "", time.Time{}, fmt.Errorf("invalid state: %w", consts.ErrNotFound) + exp, targetURI, err := a.GetStateExpirationAndURL(ctx, state) + if errors.Is(err, consts.ErrNotFound) { + return "", "", time.Time{}, fmt.Errorf("invalid state: %w", consts.ErrNotFound) } else if err != nil { - return "", time.Time{}, fmt.Errorf("failed to load state expiration: %w", err) + return "", "", time.Time{}, fmt.Errorf("failed to load state expiration: %w", err) } else if exp.Before(time.Now()) { - return "", time.Time{}, fmt.Errorf("invalid state: state expired") + return "", "", time.Time{}, fmt.Errorf("invalid state: state expired") } // obtain token and profile tkn, err := a.Config.Exchange(ctx, code) if err != nil { - return "", time.Time{}, fmt.Errorf("failed to exchange an authorization code for a token: %w", err) + return "", "", time.Time{}, fmt.Errorf("failed to exchange an authorization code for a token: %w", err) } idToken, claims, err := a.verifyIDTokenAndClaimsFromToken(ctx, tkn) if err != nil { - return "", time.Time{}, err + return "", "", time.Time{}, err } claimsJSON, _ := json.Marshal(claims) @@ -200,10 +201,10 @@ func (a *Authenticator) Exchange(ctx context.Context, state, code string) (acces "claims": json.RawMessage(claimsJSON), }, ); err != nil { - return "", time.Time{}, fmt.Errorf("failed to perform query to save tokens: %w", err) + return "", "", time.Time{}, fmt.Errorf("failed to perform query to save tokens: %w", err) } - return tkn.AccessToken, tkn.Expiry.UTC(), nil + return tkn.AccessToken, targetURI, tkn.Expiry.UTC(), nil } // VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken. diff --git a/internal/domains/authentication/store.go b/internal/domains/authentication/store.go index 90cda2c..8a497f0 100644 --- a/internal/domains/authentication/store.go +++ b/internal/domains/authentication/store.go @@ -10,10 +10,11 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) // NewState creates a new state for logging in, saving it in the database. -func (a *Authenticator) NewState(ctx context.Context) ([32]byte, error) { +func (a *Authenticator) NewState(ctx context.Context, targetURI string) ([32]byte, error) { state, err := generateRandomState() if err != nil { return state, fmt.Errorf("failed to generate random state: %w", err) @@ -21,9 +22,20 @@ func (a *Authenticator) NewState(ctx context.Context) ([32]byte, error) { if _, err = a.db.Exec( ctx, - "INSERT INTO oauth_login_states (state) VALUES (@state)", + ` + INSERT INTO + oauth_login_states ( + state, + target_uri + ) + VALUES ( + @state, + @target_uri + ) + `, pgx.NamedArgs{ - "state": state[:], + "state": state[:], + "target_uri": targetURI, }, ); err != nil { return state, fmt.Errorf("failed to execute query: %w", err) @@ -38,31 +50,41 @@ func generateRandomState() ([32]byte, error) { return b, err } -// GetStateExpiration get's the oauth state's expiration -// func (a *Authenticator) GetStateExpiration(ctx context.Context, state [32]byte) (time.Time, error) { -func (a *Authenticator) GetStateExpiration(ctx context.Context, state string) (time.Time, error) { +// GetStateExpirationAndURL get's the oauth state's expiration +func (a *Authenticator) GetStateExpirationAndURL(ctx context.Context, state string) (time.Time, string, error) { rows, err := a.db.Query( ctx, - `SELECT expiration from oauth_login_states WHERE state = ('\x' || @state)::BYTEA`, + ` + SELECT + expiration, + target_uri + FROM + oauth_login_states + WHERE + state = ('\x' || @state)::BYTEA`, pgx.NamedArgs{ - //"state": state[:], "state": state, }, ) if err != nil { - return time.Time{}, fmt.Errorf("failed to perform query: %w", err) + return time.Time{}, "", fmt.Errorf("failed to perform query: %w", err) } - exp, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[time.Time]) + type Row struct { + Expiration time.Time + Target_uri pgtype.Text + } + + r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row]) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return time.Time{}, consts.ErrNotFound + return time.Time{}, "", consts.ErrNotFound } - return time.Time{}, fmt.Errorf("failed to scan row: %w", err) + return time.Time{}, "", fmt.Errorf("failed to scan row: %w", err) } - return exp, nil + return r.Expiration, r.Target_uri.String, nil } // TODO: need to automatically clean up expired tokens diff --git a/internal/site/auth.go b/internal/site/auth.go index 603f63a..0883be0 100644 --- a/internal/site/auth.go +++ b/internal/site/auth.go @@ -86,7 +86,8 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions . return func(r *http.Request) (response.Response, error) { ck, err := r.Cookie("access_token") if err != nil { - return response.TemporaryRedirect("/"), nil + return response.TemporaryRedirect("/"). + JSON("no access_token cookie provided"), nil } ctx := r.Context() @@ -96,7 +97,12 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions . claims, expiration, err := s.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken) if err != nil { if errors.Is(err, consts.ErrNotFound) { - return response.TemporaryRedirect("/"), nil + u, err := s.newLoginURL(ctx, r.URL.String()) + if err != nil { + return nil, response.Errorf("failed to generate login url: %w", err) + } + + return response.TemporaryRedirect(u), nil } return nil, response.Errorf("failed to authenticate: %w", err) @@ -104,10 +110,6 @@ func (s *Server) authenticateAndAddIdentity(f response.HandlerFunc, assertions . now := time.Now() - if expiration.Before(now) { - return response.TemporaryRedirect("/").Cookie(getExpiredCookie("access_token")), nil - } - // refresh tokens, when the access token is "old enough" // id token lifetime is 48 hours, allowing a person to use the app everyday comfortably, with wiggle room, without having to log in. diff --git a/internal/site/login.go b/internal/site/login.go index 1c2ba17..bc8e299 100644 --- a/internal/site/login.go +++ b/internal/site/login.go @@ -1,6 +1,7 @@ package site import ( + "context" "fmt" "net/http" "ruben/inventory2/internal/site/response" @@ -11,14 +12,23 @@ import ( func (s *Server) loginPage(r *http.Request) (response.Response, error) { ctx := r.Context() - state, err := s.auth.NewState(ctx) + u, err := s.newLoginURL(ctx, "/") if err != nil { - return nil, response.Errorf("failed to generate random state: %w", err) + return nil, err + } + + return response.TemporaryRedirect(u), nil +} + +func (s *Server) newLoginURL(ctx context.Context, targetURI string) (string, error) { + state, err := s.auth.NewState(ctx, targetURI) + if err != nil { + return "", fmt.Errorf("failed to generate random state: %w", err) } base64EncodedState := fmt.Sprintf("%x", state[:]) - return response.TemporaryRedirect(s.auth.AuthCodeURL(base64EncodedState)), nil + return s.auth.AuthCodeURL(base64EncodedState), nil } // POST /login @@ -44,7 +54,7 @@ func (s *Server) loginCallback(r *http.Request) (response.Response, error) { // obtain token and profile - accessToken, expiration, err := s.auth.Exchange(ctx, q.Get("state"), q.Get("code")) + accessToken, targetURI, expiration, err := s.auth.Exchange(ctx, q.Get("state"), q.Get("code")) if err != nil { return nil, response.Unauthorized(). Msg(fmt.Sprintf("Failed to exchange an authorization code for a token")). @@ -53,7 +63,7 @@ func (s *Server) loginCallback(r *http.Request) (response.Response, error) { // set access_token cookie and redirect to a reasonable place - return response.TemporaryRedirect("/"). + return response.TemporaryRedirect(targetURI). Cookie(newAccessTokenCookie(accessToken, expiration)), nil } diff --git a/internal/site/site.go b/internal/site/server.go similarity index 92% rename from internal/site/site.go rename to internal/site/server.go index ef37fe6..4165919 100644 --- a/internal/site/site.go +++ b/internal/site/server.go @@ -115,7 +115,13 @@ func NewServer( }))) mux.Handle("GET /styles/", http.StripPrefix("/styles", http.FileServer(http.Dir(contentDir+"/styles")))) - mux.HandleFunc("GET /", response.Handler(s.addIdentity(s.serveTemplates))) + // webpages + + // all non-authenticated webpages + mux.HandleFunc("GET /{$}", response.Handler(s.addIdentity(s.serveTemplates))) + + // all authenticated webpages + mux.HandleFunc("GET /", response.Handler(s.authenticateAndAddIdentity(s.serveTemplates))) s.Handler = mux