Skip to content

Commit 3ab3c3b

Browse files
committed
implement oauth
1 parent 78a38f8 commit 3ab3c3b

15 files changed

Lines changed: 409 additions & 36 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM golang:1.22-alpine AS builder
1+
FROM golang:1.25-alpine AS builder
22
WORKDIR /app
33
RUN apk add --no-cache git ca-certificates
44
COPY go.mod go.sum ./

cmd/server/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func main() {
4444
h := handler.New(queries, cfg)
4545

4646
// Setup router
47-
r := router.New(h, cfg.JWT.Secret)
47+
r := router.New(h, queries, cfg.JWT.Secret)
4848

4949
// Create server
5050
addr := cfg.Server.Host + ":" + cfg.Server.Port

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,20 @@ require (
88
github.com/google/uuid v1.6.0
99
github.com/ilyakaznacheev/cleanenv v1.5.0
1010
github.com/jackc/pgx/v5 v5.8.0
11+
golang.org/x/crypto v0.47.0
12+
golang.org/x/oauth2 v0.34.0
1113
)
1214

1315
require (
16+
cloud.google.com/go/compute/metadata v0.3.0 // indirect
1417
github.com/BurntSushi/toml v1.2.1 // indirect
1518
github.com/jackc/pgpassfile v1.0.0 // indirect
1619
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
20+
github.com/jackc/puddle/v2 v2.2.2 // indirect
1721
github.com/joho/godotenv v1.5.1 // indirect
1822
github.com/kr/text v0.2.0 // indirect
1923
github.com/rogpeppe/go-internal v1.14.1 // indirect
24+
golang.org/x/sync v0.19.0 // indirect
2025
golang.org/x/text v0.33.0 // indirect
2126
gopkg.in/yaml.v3 v3.0.1 // indirect
2227
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
2+
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
13
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
24
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
35
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -35,6 +37,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
3537
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
3638
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
3739
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
40+
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
41+
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
42+
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
43+
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
3844
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
3945
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
4046
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=

internal/config/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ type CookieConfig struct {
3333
}
3434

3535
type OAuthConfig struct {
36-
Google GoogleOAuthConfig
37-
Microsoft MicrosoftOAuthConfig
36+
Google GoogleOAuthConfig
37+
Microsoft MicrosoftOAuthConfig
38+
RedirectURL string `env:"AUTH_REDIRECT_URL" env-default:"/"`
3839
}
3940

4041
type GoogleOAuthConfig struct {

internal/database/connection.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package database
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/jackc/pgx/v5/pgxpool"
9+
)
10+
11+
// NewPool creates a new PostgreSQL connection pool
12+
func NewPool(ctx context.Context, connString string) (*pgxpool.Pool, error) {
13+
config, err := pgxpool.ParseConfig(connString)
14+
if err != nil {
15+
return nil, fmt.Errorf("failed to parse config: %w", err)
16+
}
17+
18+
config.MaxConns = 25
19+
config.MinConns = 2
20+
config.MaxConnLifetime = 30 * time.Minute
21+
config.MaxConnIdleTime = 5 * time.Minute
22+
23+
pool, err := pgxpool.NewWithConfig(ctx, config)
24+
if err != nil {
25+
return nil, fmt.Errorf("failed to create pool: %w", err)
26+
}
27+
28+
if err := pool.Ping(ctx); err != nil {
29+
return nil, fmt.Errorf("failed to ping database: %w", err)
30+
}
31+
32+
return pool, nil
33+
}

internal/database/queries.sql

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ RETURNING *;
1414

1515
-- name: UpdateUser :one
1616
UPDATE users
17-
SET first_name = COALESCE($2, first_name),
18-
last_name = COALESCE($3, last_name),
19-
personal_email = COALESCE($4, personal_email),
20-
school_email = COALESCE($5, school_email),
21-
phone = COALESCE($6, phone),
22-
grad_year = COALESCE($7, grad_year),
23-
role = COALESCE($8, role)
17+
SET first_name = COALESCE(sqlc.narg('first_name'), first_name),
18+
last_name = COALESCE(sqlc.narg('last_name'), last_name),
19+
personal_email = COALESCE(sqlc.narg('personal_email'), personal_email),
20+
school_email = COALESCE(sqlc.narg('school_email'), school_email),
21+
phone = COALESCE(sqlc.narg('phone'), phone),
22+
grad_year = COALESCE(sqlc.narg('grad_year'), grad_year),
23+
role = COALESCE(sqlc.narg('role'), role)
2424
WHERE uid = $1
2525
RETURNING *;
2626

@@ -40,7 +40,7 @@ RETURNING *;
4040

4141
-- name: UpdateOrganization :one
4242
UPDATE organizations
43-
SET name = COALESCE($2, name)
43+
SET name = COALESCE(sqlc.narg('name'), name)
4444
WHERE oid = $1
4545
RETURNING *;
4646

@@ -93,9 +93,9 @@ RETURNING *;
9393

9494
-- name: UpdateEvent :one
9595
UPDATE events
96-
SET location = COALESCE($2, location),
97-
event_time = COALESCE($3, event_time),
98-
description = COALESCE($4, description)
96+
SET location = COALESCE(sqlc.narg('location'), location),
97+
event_time = COALESCE(sqlc.narg('event_time'), event_time),
98+
description = COALESCE(sqlc.narg('description'), description)
9999
WHERE eid = $1
100100
RETURNING *;
101101

internal/database/queries.sql.go

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/handler/auth.go

Lines changed: 147 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package handler
22

33
import (
4+
"context"
45
"crypto/rand"
56
"encoding/hex"
67
"encoding/json"
@@ -9,9 +10,11 @@ import (
910

1011
"github.com/capyrpi/api/internal/database"
1112
"github.com/capyrpi/api/internal/middleware"
13+
"github.com/capyrpi/api/internal/oauth"
1214
"github.com/go-chi/chi/v5"
1315
"github.com/golang-jwt/jwt/v5"
1416
"github.com/google/uuid"
17+
"github.com/jackc/pgx/v5"
1518
"github.com/jackc/pgx/v5/pgtype"
1619
"golang.org/x/crypto/bcrypt"
1720
)
@@ -58,8 +61,16 @@ type CreateBotTokenRequest struct {
5861
// @Success 302
5962
// @Router /auth/google [get]
6063
func (h *Handler) GoogleAuth(w http.ResponseWriter, r *http.Request) {
61-
// TODO: Implement Google OAuth redirect
62-
h.respondError(w, http.StatusNotImplemented, "Google OAuth not yet implemented")
64+
state, err := oauth.GenerateStateToken()
65+
if err != nil {
66+
h.respondError(w, http.StatusInternalServerError, "Failed to generate state")
67+
return
68+
}
69+
70+
// Set state cookie to verify callback
71+
h.setStateCookie(w, state)
72+
73+
http.Redirect(w, r, h.googleAuth.GetAuthURL(state), http.StatusFound)
6374
}
6475

6576
// GoogleCallback handles Google OAuth callback
@@ -72,8 +83,39 @@ func (h *Handler) GoogleAuth(w http.ResponseWriter, r *http.Request) {
7283
// @Failure 400 {object} ErrorResponse
7384
// @Router /auth/google/callback [get]
7485
func (h *Handler) GoogleCallback(w http.ResponseWriter, r *http.Request) {
75-
// TODO: Implement Google OAuth callback
76-
h.respondError(w, http.StatusNotImplemented, "Google OAuth not yet implemented")
86+
// Verify state
87+
state := r.URL.Query().Get("state")
88+
if !h.verifyStateCookie(w, r, state) {
89+
h.respondError(w, http.StatusBadRequest, "Invalid state parameter")
90+
return
91+
}
92+
93+
code := r.URL.Query().Get("code")
94+
if code == "" {
95+
h.respondError(w, http.StatusBadRequest, "Missing auth code")
96+
return
97+
}
98+
99+
userInfo, err := h.googleAuth.ExchangeCode(r.Context(), code)
100+
if err != nil {
101+
h.respondError(w, http.StatusInternalServerError, "Failed to exchange code")
102+
return
103+
}
104+
105+
user, err := h.upsertUser(r.Context(), userInfo.Email, userInfo.GivenName, userInfo.FamilyName)
106+
if err != nil {
107+
h.handleDBError(w, err)
108+
return
109+
}
110+
111+
token, err := h.generateJWT(user)
112+
if err != nil {
113+
h.respondError(w, http.StatusInternalServerError, "Failed to generate session")
114+
return
115+
}
116+
117+
h.setAuthCookie(w, token)
118+
http.Redirect(w, r, h.config.OAuth.RedirectURL, http.StatusFound)
77119
}
78120

79121
// MicrosoftAuth initiates Microsoft OAuth flow
@@ -83,8 +125,14 @@ func (h *Handler) GoogleCallback(w http.ResponseWriter, r *http.Request) {
83125
// @Success 302
84126
// @Router /auth/microsoft [get]
85127
func (h *Handler) MicrosoftAuth(w http.ResponseWriter, r *http.Request) {
86-
// TODO: Implement Microsoft OAuth redirect
87-
h.respondError(w, http.StatusNotImplemented, "Microsoft OAuth not yet implemented")
128+
state, err := oauth.GenerateStateToken()
129+
if err != nil {
130+
h.respondError(w, http.StatusInternalServerError, "Failed to generate state")
131+
return
132+
}
133+
134+
h.setStateCookie(w, state)
135+
http.Redirect(w, r, h.microsoftAuth.GetAuthURL(state), http.StatusFound)
88136
}
89137

90138
// MicrosoftCallback handles Microsoft OAuth callback
@@ -97,8 +145,44 @@ func (h *Handler) MicrosoftAuth(w http.ResponseWriter, r *http.Request) {
97145
// @Failure 400 {object} ErrorResponse
98146
// @Router /auth/microsoft/callback [get]
99147
func (h *Handler) MicrosoftCallback(w http.ResponseWriter, r *http.Request) {
100-
// TODO: Implement Microsoft OAuth callback
101-
h.respondError(w, http.StatusNotImplemented, "Microsoft OAuth not yet implemented")
148+
state := r.URL.Query().Get("state")
149+
if !h.verifyStateCookie(w, r, state) {
150+
h.respondError(w, http.StatusBadRequest, "Invalid state parameter")
151+
return
152+
}
153+
154+
code := r.URL.Query().Get("code")
155+
if code == "" {
156+
h.respondError(w, http.StatusBadRequest, "Missing auth code")
157+
return
158+
}
159+
160+
userInfo, err := h.microsoftAuth.ExchangeCode(r.Context(), code)
161+
if err != nil {
162+
h.respondError(w, http.StatusInternalServerError, "Failed to exchange code")
163+
return
164+
}
165+
166+
// Use PrincipalName (email) or Mail
167+
email := userInfo.UserPrincipalName
168+
if email == "" {
169+
email = userInfo.Mail
170+
}
171+
172+
user, err := h.upsertUser(r.Context(), email, userInfo.GivenName, userInfo.Surname)
173+
if err != nil {
174+
h.handleDBError(w, err)
175+
return
176+
}
177+
178+
token, err := h.generateJWT(user)
179+
if err != nil {
180+
h.respondError(w, http.StatusInternalServerError, "Failed to generate session")
181+
return
182+
}
183+
184+
h.setAuthCookie(w, token)
185+
http.Redirect(w, r, h.config.OAuth.RedirectURL, http.StatusFound)
102186
}
103187

104188
// ============================================================================
@@ -400,6 +484,61 @@ func (h *Handler) setAuthCookie(w http.ResponseWriter, token string) {
400484
})
401485
}
402486

487+
func (h *Handler) setStateCookie(w http.ResponseWriter, state string) {
488+
http.SetCookie(w, &http.Cookie{
489+
Name: "oauth_state",
490+
Value: state,
491+
Path: "/v1/auth",
492+
Domain: h.config.Cookie.Domain,
493+
MaxAge: 300, // 5 minutes
494+
Secure: h.config.Cookie.Secure,
495+
HttpOnly: true,
496+
SameSite: http.SameSiteLaxMode,
497+
})
498+
}
499+
500+
func (h *Handler) verifyStateCookie(w http.ResponseWriter, r *http.Request, state string) bool {
501+
cookie, err := r.Cookie("oauth_state")
502+
if err != nil {
503+
return false
504+
}
505+
// Clear cookie
506+
http.SetCookie(w, &http.Cookie{
507+
Name: "oauth_state",
508+
Value: "",
509+
Path: "/v1/auth",
510+
Domain: h.config.Cookie.Domain,
511+
MaxAge: -1,
512+
Secure: h.config.Cookie.Secure,
513+
HttpOnly: true,
514+
SameSite: http.SameSiteLaxMode,
515+
})
516+
return cookie.Value == state
517+
}
518+
519+
func (h *Handler) upsertUser(ctx context.Context, email, firstName, lastName string) (database.User, error) {
520+
pgEmail := toPgTextFromString(email)
521+
522+
// Check if user exists
523+
user, err := h.queries.GetUserByEmail(ctx, pgEmail)
524+
if err == nil {
525+
return user, nil
526+
}
527+
528+
if err != pgx.ErrNoRows {
529+
return database.User{}, err
530+
}
531+
532+
// Create new user
533+
return h.queries.CreateUser(ctx, database.CreateUserParams{
534+
FirstName: firstName,
535+
LastName: lastName,
536+
PersonalEmail: pgEmail, // Default to personal email for oauth
537+
SchoolEmail: pgtype.Text{Valid: false},
538+
Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, // Default role
539+
})
540+
}
541+
403542
func getEmail(user database.User) string {
404543
if user.SchoolEmail.Valid {
405544
return user.SchoolEmail.String

internal/handler/events.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ func (h *Handler) RegisterForEvent(w http.ResponseWriter, r *http.Request) {
264264
if err := h.queries.RegisterForEvent(r.Context(), database.RegisterForEventParams{
265265
Uid: *req.UID,
266266
Eid: eid,
267-
IsAttending: req.IsAttending,
267+
IsAttending: pgtype.Bool{Bool: req.IsAttending, Valid: true},
268268
}); err != nil {
269269
h.handleDBError(w, err)
270270
return

0 commit comments

Comments
 (0)