-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathauth.js
More file actions
189 lines (168 loc) · 5.79 KB
/
Copy pathauth.js
File metadata and controls
189 lines (168 loc) · 5.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import {
canAccessDashboard,
getAccessibleFeatures,
getAllPermissions,
isAdmin,
} from './lib/permissions.js'
/**
* Dev-only auth bypass. Active ONLY in non-production with an explicit opt-in flag.
* Lets a local developer obtain an admin session without a real Google login.
* Fails closed: in production the provider is never registered, regardless of the flag.
*/
export const devBypassEnabled =
process.env.NODE_ENV !== 'production' && process.env.AUTH_DEV_BYPASS === 'true'
/**
* Check if an email is authorized to access the admin dashboard
* @param {string} email
* @returns {boolean}
*/
function isAuthorizedEmail(email) {
return canAccessDashboard(email)
}
const providers = [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
authorization: {
params: {
access_type: 'offline',
// Always show Google's account picker so members logged into a personal
// Gmail in the browser can choose their @sociedadastronomia.com account.
prompt: 'select_account consent',
scope: 'openid email profile',
},
},
}),
]
if (devBypassEnabled) {
// Mock credentials provider: authorizes a single configurable dev identity.
// The user's permissions still flow through ADMIN_PERMISSIONS via the jwt/session
// callbacks. No real Google access token is issued (accessToken stays null), so any
// Apps Script / Drive call fails gracefully — reinforcing prod-data isolation.
providers.push(
Credentials({
id: 'dev-bypass',
name: 'Dev Bypass',
credentials: {},
authorize() {
return {
id: 'dev-bypass',
email: (process.env.AUTH_DEV_EMAIL || 'dev@local.test').toLowerCase(),
name: 'Dev Admin',
}
},
})
)
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers,
callbacks: {
/**
* Control whether a user is allowed to sign in
*/
signIn({ user, account }) {
// Dev-only bypass: allow the mock provider when explicitly enabled.
if (devBypassEnabled && account?.provider === 'dev-bypass') {
return true
}
const email = user.email?.toLowerCase()
if (!email) {
console.log('Sign-in attempt without email')
return false
}
if (isAuthorizedEmail(email)) {
return true
}
// SAC member domain
if (email.endsWith('@sociedadastronomia.com')) {
return true
}
console.log(`Unauthorized login attempt: ${email}`)
return false
},
/**
* Persist user ID and Google OAuth tokens in the JWT.
* On initial sign-in, account contains the tokens.
* On subsequent requests, refresh the access token if expired.
*/
async jwt({ token, user, account }) {
// Initial sign-in: capture OAuth tokens
if (account) {
token.id = user?.id
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
token.expiresAt = account.expires_at // epoch seconds
// Role determination
const email = token.email?.toLowerCase()
token.isMember = email?.endsWith('@sociedadastronomia.com') || false
token.isAdmin = isAuthorizedEmail(email) || false
// For members, also store the sign-in email as their SAC email
// (members sign in with their SAC Google account, so this IS their SAC email)
if (token.isMember) {
token.sacEmail = email
}
return token
}
// Not expired yet — return as-is
if (token.expiresAt && Date.now() < token.expiresAt * 1000) {
return token
}
// Expired — refresh using the refresh token
if (token.refreshToken) {
try {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: token.refreshToken,
}),
})
const data = await response.json()
if (data.access_token) {
token.accessToken = data.access_token
token.expiresAt = Math.floor(Date.now() / 1000) + data.expires_in
}
} catch (err) {
console.error('Failed to refresh Google token:', err.message)
}
}
return token
},
/**
* Include user ID and access token in the session.
* accessToken is used server-side by API routes to call Apps Script.
* Also include user permissions for client-side access control.
*/
session({ session, token }) {
if (token.id && session.user) {
session.user.id = token.id
}
session.accessToken = token.accessToken
// Add user permissions to session for client-side access control
if (session.user?.email) {
session.user.accessibleFeatures = getAccessibleFeatures(session.user.email)
session.user.accessibleActions = getAllPermissions(session.user.email)
session.user.isAdmin = canAccessDashboard(session.user.email)
session.user.isFullAdmin = isAdmin(session.user.email)
// Member flag and SAC email from JWT
session.user.isMember = token.isMember || false
session.user.sacEmail = token.sacEmail || session.user.email
}
return session
},
},
pages: {
signIn: '/auth/signin',
error: '/auth/error',
},
session: {
strategy: 'jwt',
maxAge: 24 * 60 * 60, // 24 hours
},
})