BookMe uses a two-phase authentication system: OAuth 2.0 for initial login and JWT tokens for API access.
Authentication Flow
The complete authentication flow involves:
- OAuth Login: User authenticates via 42 Intranet
- Token Issuance: Server generates JWT access token
- API Access: Client includes JWT in subsequent requests
User → 42 OAuth → BookMe Server → JWT Token → Client
↓
API Requests
OAuth 2.0 Integration
BookMe integrates with the 42 Network OAuth provider for initial authentication.
OAuth Configuration
The OAuth client is configured in internal/api/api.go:57:
oauthConfig := &oauth2.Config{
ClientID: cfg.App.ClientID,
ClientSecret: cfg.App.ClientSecret,
RedirectURL: cfg.App.RedirectURI,
Scopes: []string{"public"},
Endpoint: oauth2.Endpoint{
AuthURL: cfg.App.OAuthAuthURI,
TokenURL: cfg.App.OAuthTokenURI,
},
}
OAuth credentials (CLIENT_ID, SECRET) are sensitive. Never commit them to version control. Load them from environment variables.
Login Flow
Step 1: Initiate Login (GET /oauth/login)
The OAuth service generates a random state token for CSRF protection:
// From internal/oauth/service.go:32
state := generateRandomState() // 256-bit cryptographically secure token
// Store state in session cookie
session.Values["oauth_state"] = state
session.Save(r, w)
// Redirect to 42 authorization page
url := provider.config.AuthCodeURL(state)
Step 2: OAuth Callback (GET /oauth/callback)
After user authorizes, 42 redirects back with authorization code:
// From internal/oauth/service.go:53
// 1. Validate state token (CSRF protection)
expectedState := session.Values["oauth_state"]
if expectedState != r.URL.Query().Get("state") {
return ErrInvalidOrMissingState
}
// 2. Exchange code for access token
token, err := config.Exchange(r.Context(), code)
// 3. Fetch user data from 42 API
user42, err := Fetch42UserData(ctx, token)
// 4. Validate campus affiliation
isHive := false
for _, camp := range user42.Campus {
if camp.ID == 13 && camp.Primary {
isHive = true
break
}
}
if !isHive {
return ErrInvalidCampus
}
// 5. Find or create user in database
user, err := FindOrCreateUser(ctx, user42)
Only users whose primary campus is Hive Helsinki (campus ID 13) are allowed to register. This validation happens in internal/oauth/provider42.go:75.
User Creation
If the user doesn’t exist in the database, BookMe creates them automatically:
// From internal/oauth/provider42.go:144
role := service.RoleStudent
if user42.Staff {
role = service.RoleStaff
}
user, err := db.CreateUser(ctx, database.CreateUserParams{
Email: user42.Email,
Name: user42.Name,
Role: role,
})
Roles are assigned based on the staff? field from 42 API:
- STUDENT: Regular students
- STAFF: Hive staff members with elevated privileges
Retry Logic
The 42 API integration includes automatic retry on transient failures:
// From internal/oauth/provider42.go:114
client := retryablehttp.NewClient()
client.RetryMax = 3
client.RetryWaitMin = 1 * time.Second
client.RetryWaitMax = 5 * time.Second
client.Backoff = retryablehttp.DefaultBackoff
This ensures temporary network issues don’t break the authentication flow.
JWT Token System
After successful OAuth authentication, BookMe issues a JWT access token.
Token Structure
JWT tokens contain these claims (internal/auth/auth.go:21):
type CustomClaims struct {
Name string `json:"name"` // User display name
Role string `json:"role"` // STUDENT or STAFF
jwt.RegisteredClaims
}
RegisteredClaims {
Subject: "<user_id>" // User ID as string
IssuedAt: <timestamp> // Token creation time
ExpiresAt: <timestamp> // 1 hour from issuance
Issuer: "access" // Token type identifier
}
Token Issuance
Tokens are created using HMAC-SHA256 signing:
// From internal/auth/auth.go:77
func (s *Service) IssueAccessToken(user database.User) (string, error) {
claims := CustomClaims{
Name: user.Name,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: strconv.FormatInt(user.ID, 10),
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.AccessTokenTTL)),
Issuer: string(tokenTypeAccess),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.JwtSecret))
}
Access tokens expire after 1 hour. The TTL is configured in internal/auth/auth.go:72 as AccessTokenTTL: time.Hour.
Token Verification
The authentication middleware verifies tokens on every protected request:
// From internal/auth/auth.go:99
func (s *Service) VerifyAccessToken(tokenStr string) (*CustomClaims, error) {
token, err := jwt.ParseWithClaims(
tokenStr,
&CustomClaims{},
func(token *jwt.Token) (any, error) {
// Enforce HMAC signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return []byte(s.JwtSecret), nil
},
)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrExpiredToken
}
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(*CustomClaims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
Verification checks:
- Signature validity
- Token expiration
- Signing algorithm (must be HMAC)
- Claims structure
Authentication Middleware
BookMe uses two middleware layers for authentication:
Authenticate Middleware
Extracts and validates JWT tokens, but allows requests to continue even without valid tokens:
// From internal/middleware/auth.go:13
func Authenticate(authService *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract Bearer token
tokenStr, err := auth.GetBearerToken(r.Header)
if err != nil {
// Log but continue without user
next.ServeHTTP(w, r)
return
}
// Verify token
claims, err := authService.VerifyAccessToken(tokenStr)
if err != nil {
// Log but continue without user
next.ServeHTTP(w, r)
return
}
// Add user to context
user := auth.User{
ID: id,
Role: claims.Role,
Name: claims.Name,
}
ctx := auth.WithUser(r.Context(), user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
RequireAuth Middleware
Enforces authentication by rejecting requests without a valid user in context:
// From internal/middleware/auth.go:52
func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
return
}
next.ServeHTTP(w, r)
})
}
Use Authenticate alone for endpoints that support optional authentication. Chain Authenticate + RequireAuth for protected endpoints that require authentication.
Clients must include JWT tokens in the Authorization header:
Authorization: Bearer <jwt_token>
Token extraction from headers (internal/auth/auth.go:128):
func GetBearerToken(headers http.Header) (string, error) {
authHeader := headers.Get("Authorization")
if authHeader == "" {
return "", ErrNoAuthHeaderIncluded
}
token, ok := strings.CutPrefix(authHeader, "Bearer ")
if !ok {
return "", ErrInvalidBearerToken
}
if token == "" {
return "", ErrEmptyBearerToken
}
return token, nil
}
Security Considerations
CSRF Protection
The OAuth flow uses cryptographically secure random state tokens to prevent CSRF attacks. State tokens are:
- Generated as 256-bit random values
- Stored in secure session cookies
- Validated on callback
- Deleted after use
Token Security
Secret Management
- JWT secret (
JWT_SECRET) must be at least 32 bytes
- Never expose secrets in logs or error messages
- Rotate secrets periodically in production
Token Expiration
- Access tokens expire after 1 hour
- No refresh tokens (users re-authenticate via OAuth)
- Expired tokens are rejected with
401 Unauthorized
Algorithm Enforcement
- Only HMAC-SHA256 signing is accepted
- Algorithm is validated during token verification
- Prevents algorithm substitution attacks
Session Cookies
OAuth state is stored in encrypted session cookies:
// From internal/oauth/provider42.go:56
session := sessions.NewCookieStore([]byte(sessionSecret))
The SESSION_SECRET environment variable must be a strong, random value. It encrypts session cookies. If compromised, attackers could forge OAuth state tokens.
Error Handling
Authentication errors are clearly defined in internal/auth/auth.go:49:
ErrInvalidToken: Malformed or tampered token
ErrExpiredToken: Token past expiration time
ErrEmptyBearerToken: Authorization header present but empty
ErrInvalidBearerToken: Missing “Bearer ” prefix
ErrNoAuthHeaderIncluded: No Authorization header
All authentication failures return 401 Unauthorized with a JSON error response.