Claude-assisted improvements (untested)
- dev auth flow (side-step OAuth) - db event processing integration tests - dev scripts (eg Makefile) - db / test db migration setup scripts.
This commit is contained in:
@@ -17,26 +17,11 @@ import (
|
||||
"ruben/inventory2/logging"
|
||||
)
|
||||
|
||||
// TODO: move these to a config?
|
||||
const (
|
||||
// The URL of our Auth0 Tenant Domain.
|
||||
// If you're using a Custom Domain, be sure to set this to that value instead.
|
||||
AUTH0_DOMAIN = "dev-uq3gqy5bdnwxmr6d.us.auth0.com"
|
||||
|
||||
// Our Auth0 application"s Client ID.
|
||||
AUTH0_CLIENT_ID = "JEjrXTQ9fxlTLgp9RgTIACpUk8a2lqNT"
|
||||
|
||||
// Our Auth0 application"s Client Secret.
|
||||
AUTH0_CLIENT_SECRET = "83U-iWdVaNnwk9XDzteo_2VMyOq_l1siKYqg1_2E7jCzgL8MnkaxlysPMcPMGlxA"
|
||||
|
||||
// The Callback URL of our application.
|
||||
AUTH0_CALLBACK_URL = "https://inventory-plus-plus.com/api/auth/login/callback"
|
||||
)
|
||||
|
||||
type (
|
||||
// Authenticator is used to authenticate our users.
|
||||
Authenticator struct {
|
||||
log *logging.Logger
|
||||
log *logging.Logger
|
||||
domain string
|
||||
*oidc.Provider
|
||||
oauth2.Config
|
||||
db *pgxpool.Pool
|
||||
@@ -65,10 +50,11 @@ func New(
|
||||
ctx context.Context,
|
||||
db *pgxpool.Pool,
|
||||
logger *logging.Logger,
|
||||
domain, clientID, clientSecret, callbackURL string,
|
||||
) (*Authenticator, error) {
|
||||
provider, err := oidc.NewProvider(
|
||||
ctx,
|
||||
"https://"+AUTH0_DOMAIN+"/",
|
||||
"https://"+domain+"/",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -76,11 +62,12 @@ func New(
|
||||
|
||||
return &Authenticator{
|
||||
log: logger,
|
||||
domain: domain,
|
||||
Provider: provider,
|
||||
Config: oauth2.Config{
|
||||
ClientID: AUTH0_CLIENT_ID,
|
||||
ClientSecret: AUTH0_CLIENT_SECRET,
|
||||
RedirectURL: AUTH0_CALLBACK_URL,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: callbackURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile"},
|
||||
},
|
||||
@@ -244,7 +231,7 @@ func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token)
|
||||
func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
|
||||
return &url.URL{
|
||||
Scheme: "https",
|
||||
Host: AUTH0_DOMAIN,
|
||||
Host: a.domain,
|
||||
Path: "/v2/logout",
|
||||
RawQuery: url.Values{
|
||||
"returnTo": {
|
||||
@@ -253,7 +240,7 @@ func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
|
||||
Host: requestHost,
|
||||
}).String(),
|
||||
},
|
||||
"client_id": {AUTH0_CLIENT_ID},
|
||||
"client_id": {a.Config.ClientID},
|
||||
}.Encode(),
|
||||
}
|
||||
}
|
||||
@@ -267,7 +254,7 @@ func (a *Authenticator) RefreshAccessToken(
|
||||
err error,
|
||||
) {
|
||||
|
||||
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, accessToken)
|
||||
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, oldAccessToken)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to load refresh token: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package authentication
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// DevLogin mints a local session for userID without going through the real
|
||||
// Auth0 OAuth flow. It writes a real oauth_users/oauth_tokens row, so every
|
||||
// other code path (identity lookup, account linking/creation, cookie
|
||||
// handling) treats it exactly like a normal login.
|
||||
//
|
||||
// Only ever call this from a route gated on an explicit dev-mode flag -
|
||||
// never wire it up unconditionally, since it lets the caller authenticate
|
||||
// as any user_id with no credentials.
|
||||
func (a *Authenticator) DevLogin(ctx context.Context, userID, name string) (accessToken string, expiration time.Time, err error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
accessToken = "dev_" + hex.EncodeToString(tokenBytes)
|
||||
|
||||
// Set far in the future so the near-expiry refresh path in
|
||||
// server/auth never tries to refresh a token that has no real
|
||||
// Auth0-side refresh token behind it.
|
||||
expiration = time.Now().Add(365 * 24 * time.Hour)
|
||||
|
||||
if _, err = a.db.Exec(
|
||||
ctx,
|
||||
`
|
||||
WITH ensured_user AS (
|
||||
INSERT INTO oauth_users (user_id)
|
||||
VALUES (@user_id)
|
||||
ON CONFLICT DO NOTHING
|
||||
)
|
||||
INSERT INTO oauth_tokens (
|
||||
access_token,
|
||||
token_type,
|
||||
refresh_token,
|
||||
expiry,
|
||||
|
||||
id_token_issuer,
|
||||
id_token_audience,
|
||||
id_token_subject,
|
||||
id_token_expiry,
|
||||
id_token_issued_at,
|
||||
id_token_nonce,
|
||||
id_token_access_token_hash,
|
||||
|
||||
id_token_custom_claims_family_name,
|
||||
id_token_custom_claims_given_name,
|
||||
id_token_custom_claims_name,
|
||||
id_token_custom_claims_nickname,
|
||||
id_token_custom_claims_picture,
|
||||
id_token_custom_claims_updated_at
|
||||
)
|
||||
VALUES (
|
||||
@access_token,
|
||||
'dev',
|
||||
'',
|
||||
@expiry,
|
||||
|
||||
'dev-auth-bypass',
|
||||
@audience,
|
||||
@user_id,
|
||||
@expiry,
|
||||
NOW(),
|
||||
'',
|
||||
'',
|
||||
|
||||
@name,
|
||||
@name,
|
||||
@name,
|
||||
@name,
|
||||
'',
|
||||
NOW()
|
||||
)
|
||||
`,
|
||||
pgx.NamedArgs{
|
||||
"access_token": accessToken,
|
||||
"expiry": expiration,
|
||||
"user_id": userID,
|
||||
"name": name,
|
||||
"audience": pgtype.FlatArray[string]{"dev"},
|
||||
},
|
||||
); err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to save dev session: %w", err)
|
||||
}
|
||||
|
||||
return accessToken, expiration, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package authentication
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
)
|
||||
|
||||
func TestDevLogin(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
auth := &Authenticator{db: pool}
|
||||
ctx := context.Background()
|
||||
|
||||
userID := testdb.NewUserID(t)
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM oauth_tokens WHERE id_token_subject = $1", userID)
|
||||
pool.Exec(context.Background(), "DELETE FROM oauth_users WHERE user_id = $1", userID)
|
||||
})
|
||||
|
||||
accessToken, expiration, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("DevLogin() error = %v", err)
|
||||
}
|
||||
if accessToken == "" {
|
||||
t.Fatal("DevLogin() returned an empty access token")
|
||||
}
|
||||
if !expiration.After(time.Now().Add(30 * 24 * time.Hour)) {
|
||||
t.Errorf("DevLogin() expiration = %v, want something far enough out to avoid the near-expiry refresh path", expiration)
|
||||
}
|
||||
|
||||
claims, err := auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccessTokenClaimsAndExpiration() error = %v", err)
|
||||
}
|
||||
if claims.Name != "Test User" {
|
||||
t.Errorf("claims.Name = %q, want %q", claims.Name, "Test User")
|
||||
}
|
||||
// Postgres timestamptz has microsecond precision, so the round-tripped
|
||||
// value loses the sub-microsecond portion of Go's nanosecond clock.
|
||||
if diff := claims.Expiration.Sub(expiration); diff > time.Millisecond || diff < -time.Millisecond {
|
||||
t.Errorf("claims.Expiration = %v, want ~%v (diff %v)", claims.Expiration, expiration, diff)
|
||||
}
|
||||
|
||||
_, tokenType, err := auth.getRefreshTokenForAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("getRefreshTokenForAccessToken() error = %v", err)
|
||||
}
|
||||
if tokenType != "dev" {
|
||||
t.Errorf("tokenType = %q, want %q", tokenType, "dev")
|
||||
}
|
||||
}
|
||||
|
||||
// DevLogin should be safe to call more than once for the same user_id -
|
||||
// e.g. testing multiple times as the same dev identity - since oauth_users
|
||||
// is keyed on user_id but each call mints its own oauth_tokens row.
|
||||
func TestDevLogin_SameUserIDTwice(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
auth := &Authenticator{db: pool}
|
||||
ctx := context.Background()
|
||||
|
||||
userID := testdb.NewUserID(t)
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM oauth_tokens WHERE id_token_subject = $1", userID)
|
||||
pool.Exec(context.Background(), "DELETE FROM oauth_users WHERE user_id = $1", userID)
|
||||
})
|
||||
|
||||
token1, _, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("first DevLogin() error = %v", err)
|
||||
}
|
||||
|
||||
token2, _, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("second DevLogin() error = %v", err)
|
||||
}
|
||||
|
||||
if token1 == token2 {
|
||||
t.Fatalf("DevLogin() returned the same access token twice: %q", token1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
auth := &Authenticator{db: pool}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := auth.GetAccessTokenClaimsAndExpiration(ctx, "no-such-token-"+testdb.NewUserID(t))
|
||||
if !errors.Is(err, consts.ErrNotFound) {
|
||||
t.Fatalf("GetAccessTokenClaimsAndExpiration() error = %v, want %v", err, consts.ErrNotFound)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user