save oauth user_id and link users and accounts

This commit is contained in:
2025-12-30 02:06:14 -07:00
parent bc592718bf
commit 8090a6f25e
21 changed files with 468 additions and 198 deletions
+75 -9
View File
@@ -21,8 +21,9 @@ type (
}
Account struct {
ID int64
Email string
UserID string
ID int64
Email string
}
)
@@ -39,12 +40,24 @@ func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
}
}
func (db *Store) CreateAccount(ctx context.Context, email string) (Account, error) {
func (db *Store) CreateAccount(ctx context.Context, userID, email string) (Account, error) {
rows, err := db.db.Query(
ctx,
"INSERT INTO accounts (email) VALUES (@email) RETURNING account_id",
`
INSERT INTO accounts (
user_id,
email
)
VALUES (
@user_id,
@email
)
RETURNING
account_id
`,
pgx.NamedArgs{
"email": email,
"user_id": userID,
"email": email,
},
)
if err != nil {
@@ -53,12 +66,16 @@ func (db *Store) CreateAccount(ctx context.Context, email string) (Account, erro
acctID, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, consts.ErrNotFound
}
return Account{}, fmt.Errorf("failed to scan row: %w", err)
}
return Account{
ID: acctID,
Email: email,
UserID: userID,
ID: acctID,
Email: email,
}, nil
}
@@ -89,6 +106,44 @@ func (db *Store) GetAccount(ctx context.Context, id int64) (Account, error) {
}, nil
}
func (db *Store) GetAccountByUserID(ctx context.Context, userID string) (Account, error) {
rows, err := db.db.Query(
ctx,
`SELECT
email, account_id
FROM
accounts
WHERE
user_id = @user_id`,
pgx.NamedArgs{
"user_id": userID,
},
)
if err != nil {
return Account{}, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Email string
Account_ID int64
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, consts.ErrNotFound
}
return Account{}, fmt.Errorf("failed to scan row: %w", err)
}
return Account{
UserID: userID,
ID: r.Account_ID,
Email: r.Email,
}, nil
}
func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account, error) {
rows, err := db.db.Query(
ctx,
@@ -116,10 +171,21 @@ func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account,
}, nil
}
func (db *StoreWithContext) CreateAccount(email string) (Account, error) {
return db.db.CreateAccount(db.ctx, email)
func (db *StoreWithContext) CreateAccount(userID, email string) (Account, error) {
return db.db.CreateAccount(db.ctx, userID, email)
}
func (db *StoreWithContext) GetAccount(id int64) (Account, error) {
return db.db.GetAccount(db.ctx, id)
}
func (db *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) {
acct, err := db.db.GetAccountByUserID(db.ctx, userID)
if err == nil {
return &acct, nil
}
if errors.Is(err, consts.ErrNotFound) {
return nil, nil
}
return nil, err
}
+52 -134
View File
@@ -2,7 +2,6 @@ package authentication
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
@@ -82,90 +81,7 @@ func New(ctx context.Context, db *pgxpool.Pool) (*Authenticator, error) {
}, nil
}
// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, errors.New("no id_token field in oauth2 token")
}
oidcConfig := &oidc.Config{
ClientID: a.ClientID,
}
return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
}
// NewState creates a new state for logging in, saving it in the database.
func (a *Authenticator) NewState(ctx context.Context) ([32]byte, error) {
state, err := generateRandomState()
if err != nil {
return state, fmt.Errorf("failed to generate random state: %w", err)
}
if _, err = a.db.Exec(
ctx,
"INSERT INTO oauth_login_states (state) VALUES (@state)",
pgx.NamedArgs{
"state": state[:],
},
); err != nil {
return state, fmt.Errorf("failed to execute query: %w", err)
}
return state, nil
}
func generateRandomState() ([32]byte, error) {
var b [32]byte
_, err := rand.Read(b[:])
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) {
rows, err := a.db.Query(
ctx,
`SELECT expiration 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)
}
exp, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[time.Time])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return time.Time{}, consts.ErrNotFound
}
return time.Time{}, fmt.Errorf("failed to scan row: %w", err)
}
return exp, nil
}
func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
return &url.URL{
Scheme: "https",
Host: AUTH0_DOMAIN,
Path: "/v2/logout",
RawQuery: url.Values{
"returnTo": {
(&url.URL{
Scheme: "https",
Host: requestHost,
}).String(),
},
"client_id": {AUTH0_CLIENT_ID},
}.Encode(),
}
}
// 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) {
// validate state
@@ -193,8 +109,6 @@ func (a *Authenticator) Exchange(ctx context.Context, state, code string) (acces
fmt.Println("ID TOKEN:", idToken)
// claims []byte
var claims map[string]any
if err := idToken.Claims(&claims); err != nil {
return "", time.Time{}, fmt.Errorf("Failed to obtain id token claims: %w", err)
@@ -204,9 +118,30 @@ func (a *Authenticator) Exchange(ctx context.Context, state, code string) (acces
claimsJSON, _ := json.Marshal(claims)
// store the token, and the potentially new user
if _, err := a.db.Exec(
ctx,
`
WITH new_user AS (
INSERT INTO oauth_users (
user_id
)
VALUES (
@id_token_subject
)
ON CONFLICT DO NOTHING
RETURNING
user_id
), the_user AS (
SELECT
COALESCE(user_id, user_id_2) as user_id
FROM
new_user
RIGHT JOIN
(SELECT @id_token_subject as user_id_2)
ON TRUE
)
INSERT INTO oauth_tokens (
access_token,
token_type,
@@ -221,9 +156,9 @@ func (a *Authenticator) Exchange(ctx context.Context, state, code string) (acces
id_token_nonce,
id_token_access_token_hash,
claims
claims -- might want to open this up
)
VALUES (
SELECT
@access_token,
@token_type,
@refresh_token,
@@ -231,14 +166,15 @@ func (a *Authenticator) Exchange(ctx context.Context, state, code string) (acces
@id_token_issuer,
@id_token_audience,
@id_token_subject,
user_id,
@id_token_expiry,
@id_token_issued_at,
@id_token_nonce,
@id_token_access_token_hash,
@claims
)
FROM
the_user
`,
pgx.NamedArgs{
"access_token": token.AccessToken,
@@ -263,51 +199,33 @@ func (a *Authenticator) Exchange(ctx context.Context, state, code string) (acces
return token.AccessToken, token.Expiry.UTC(), nil
}
// TODO: need to automatically clean up expired tokens
func (a *Authenticator) DeleteOAuthTokens(ctx context.Context, accessToken string) error {
_, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE access_token = @access_token", pgx.NamedArgs{"access_token": accessToken})
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, errors.New("no id_token field in oauth2 token")
}
return nil
oidcConfig := &oidc.Config{
ClientID: a.ClientID,
}
return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
}
func (a *Authenticator) GetAccessTokenClaimsAndExpiration(ctx context.Context, accessToken string) (claims AccessTokenClaims, expiration time.Time, err error) {
rows, err := a.db.Query(
ctx,
`
SELECT
expiry,
claims
FROM
oauth_tokens
WHERE
access_token = @access_token
`,
pgx.NamedArgs{
"access_token": accessToken,
},
)
if err != nil {
return AccessTokenClaims{}, time.Time{}, fmt.Errorf("failed to perform query: %w", err)
func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
return &url.URL{
Scheme: "https",
Host: AUTH0_DOMAIN,
Path: "/v2/logout",
RawQuery: url.Values{
"returnTo": {
(&url.URL{
Scheme: "https",
Host: requestHost,
}).String(),
},
"client_id": {AUTH0_CLIENT_ID},
}.Encode(),
}
type Row struct {
Expiry time.Time
Claims json.RawMessage
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AccessTokenClaims{}, time.Time{}, consts.ErrNotFound
}
return AccessTokenClaims{}, time.Time{}, fmt.Errorf("failed to scan row: %w", err)
}
if err := json.Unmarshal(r.Claims, &claims); err != nil {
return AccessTokenClaims{}, time.Time{}, fmt.Errorf("failed to scan claims json: %w", err)
}
return claims, r.Expiry, nil
}
+115
View File
@@ -0,0 +1,115 @@
package authentication
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"ruben/inventory2/internal/consts"
"time"
"github.com/jackc/pgx/v5"
)
// NewState creates a new state for logging in, saving it in the database.
func (a *Authenticator) NewState(ctx context.Context) ([32]byte, error) {
state, err := generateRandomState()
if err != nil {
return state, fmt.Errorf("failed to generate random state: %w", err)
}
if _, err = a.db.Exec(
ctx,
"INSERT INTO oauth_login_states (state) VALUES (@state)",
pgx.NamedArgs{
"state": state[:],
},
); err != nil {
return state, fmt.Errorf("failed to execute query: %w", err)
}
return state, nil
}
func generateRandomState() ([32]byte, error) {
var b [32]byte
_, err := rand.Read(b[:])
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) {
rows, err := a.db.Query(
ctx,
`SELECT expiration 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)
}
exp, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[time.Time])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return time.Time{}, consts.ErrNotFound
}
return time.Time{}, fmt.Errorf("failed to scan row: %w", err)
}
return exp, nil
}
// TODO: need to automatically clean up expired tokens
func (a *Authenticator) DeleteOAuthTokens(ctx context.Context, accessToken string) error {
_, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE access_token = @access_token", pgx.NamedArgs{"access_token": accessToken})
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
func (a *Authenticator) GetAccessTokenClaimsAndExpiration(ctx context.Context, accessToken string) (claims AccessTokenClaims, expiration time.Time, err error) {
rows, err := a.db.Query(
ctx,
`
SELECT
expiry,
claims
FROM
oauth_tokens
WHERE
access_token = @access_token
`,
pgx.NamedArgs{
"access_token": accessToken,
},
)
if err != nil {
return AccessTokenClaims{}, time.Time{}, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Expiry time.Time
Claims json.RawMessage
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AccessTokenClaims{}, time.Time{}, consts.ErrNotFound
}
return AccessTokenClaims{}, time.Time{}, fmt.Errorf("failed to scan row: %w", err)
}
if err := json.Unmarshal(r.Claims, &claims); err != nil {
return AccessTokenClaims{}, time.Time{}, fmt.Errorf("failed to scan claims json: %w", err)
}
return claims, r.Expiry, nil
}