@teleporthq/teleport-plugin-next-workflows 0.43.46 → 0.43.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/auth-env-secret-preservation.test.ts +35 -0
- package/__tests__/auth-nextauth-url.test.ts +28 -0
- package/__tests__/auth-session-cost.test.ts +614 -0
- package/__tests__/custom-js-params-env-invariance.test.ts +134 -0
- package/__tests__/data-node-not-awaited.test.ts +644 -0
- package/__tests__/element-trigger-static-attr-replace.test.ts +99 -0
- package/__tests__/element-visible-trigger-scoping.test.ts +296 -0
- package/__tests__/payment-charge-user-handler-serialization.test.ts +7 -5
- package/__tests__/payment-redirect-and-order-notification.test.ts +6 -4
- package/__tests__/raw-sql-param-binding.test.ts +82 -0
- package/__tests__/reserved-word-state-binding.test.ts +112 -0
- package/__tests__/runtime-stringified-boolean-truthiness.test.ts +76 -0
- package/__tests__/strip-event-handler-attrs.test.ts +59 -0
- package/__tests__/workflow-auth-guard.test.ts +277 -0
- package/dist/cjs/api-route-generator.d.ts +3 -3
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +136 -11
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts +67 -1
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +458 -20
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/await-result.d.ts +31 -0
- package/dist/cjs/await-result.d.ts.map +1 -0
- package/dist/cjs/await-result.js +50 -0
- package/dist/cjs/await-result.js.map +1 -0
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +162 -3
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/nodes/account/account-get-current.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-get-current.js +44 -11
- package/dist/cjs/nodes/account/account-get-current.js.map +1 -1
- package/dist/cjs/nodes/account/account-login.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-login.js +10 -1
- package/dist/cjs/nodes/account/account-login.js.map +1 -1
- package/dist/cjs/raw-sql-param-binding.d.ts.map +1 -1
- package/dist/cjs/raw-sql-param-binding.js +32 -0
- package/dist/cjs/raw-sql-param-binding.js.map +1 -1
- package/dist/cjs/segment-splitter.d.ts.map +1 -1
- package/dist/cjs/segment-splitter.js +15 -7
- package/dist/cjs/segment-splitter.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-auth-generator.d.ts +26 -0
- package/dist/cjs/workflow-auth-generator.d.ts.map +1 -0
- package/dist/cjs/workflow-auth-generator.js +166 -0
- package/dist/cjs/workflow-auth-generator.js.map +1 -0
- package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-component-plugin.js +107 -16
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts +2 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +170 -20
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts +3 -3
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +136 -11
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts +67 -1
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +453 -19
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/await-result.d.ts +31 -0
- package/dist/esm/await-result.d.ts.map +1 -0
- package/dist/esm/await-result.js +45 -0
- package/dist/esm/await-result.js.map +1 -0
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +162 -3
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/nodes/account/account-get-current.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-get-current.js +44 -11
- package/dist/esm/nodes/account/account-get-current.js.map +1 -1
- package/dist/esm/nodes/account/account-login.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-login.js +10 -1
- package/dist/esm/nodes/account/account-login.js.map +1 -1
- package/dist/esm/raw-sql-param-binding.d.ts.map +1 -1
- package/dist/esm/raw-sql-param-binding.js +32 -0
- package/dist/esm/raw-sql-param-binding.js.map +1 -1
- package/dist/esm/segment-splitter.d.ts.map +1 -1
- package/dist/esm/segment-splitter.js +15 -7
- package/dist/esm/segment-splitter.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-auth-generator.d.ts +26 -0
- package/dist/esm/workflow-auth-generator.d.ts.map +1 -0
- package/dist/esm/workflow-auth-generator.js +161 -0
- package/dist/esm/workflow-auth-generator.js.map +1 -0
- package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-component-plugin.js +108 -17
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts +2 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +169 -20
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/api-route-generator.ts +139 -11
- package/src/auth-generator.ts +471 -19
- package/src/await-result.ts +52 -0
- package/src/executor-generator.ts +162 -3
- package/src/nodes/account/account-get-current.ts +44 -9
- package/src/nodes/account/account-login.ts +12 -1
- package/src/raw-sql-param-binding.ts +33 -0
- package/src/segment-splitter.ts +15 -7
- package/src/workflow-auth-generator.ts +187 -0
- package/src/workflow-component-plugin.ts +129 -20
- package/src/workflow-project-plugin.ts +186 -19
|
@@ -118,4 +118,39 @@ describe('resolveAuthEnvValue', () => {
|
|
|
118
118
|
expect(resolveAuthEnvValue('TELEPORT_DB_SSL', 'true', oauthKeys)).toBe('true')
|
|
119
119
|
expect(resolveAuthEnvValue('AUTH_CREDENTIALS_ENABLED', 'true', oauthKeys)).toBe('true')
|
|
120
120
|
})
|
|
121
|
+
|
|
122
|
+
// Regression: next-auth v4 `react/index.js` runs `parseUrl(process.env.NEXTAUTH_URL)`
|
|
123
|
+
// at module load, and `new URL('')` throws "Invalid URL" — crashing SSR for
|
|
124
|
+
// every page that mounts SessionProvider. `createEnvFiles` writes every key,
|
|
125
|
+
// so a BLANK NEXTAUTH_URL serializes as the crashing `NEXTAUTH_URL=`. The
|
|
126
|
+
// blank case is self-perpetuating (the standalone harness re-injects the
|
|
127
|
+
// on-disk empty value), so the resolver must heal it, not pass it through.
|
|
128
|
+
describe('blank auth-critical keys heal to their local default', () => {
|
|
129
|
+
it.each(['', ' ', undefined as unknown as string])(
|
|
130
|
+
'NEXTAUTH_URL=%p becomes the localhost default rather than an empty assignment',
|
|
131
|
+
(blank) => {
|
|
132
|
+
expect(resolveAuthEnvValue('NEXTAUTH_URL', blank, oauthKeys)).toBe('http://localhost:3000')
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
it('a blank NEXTAUTH_SECRET heals to the placeholder default', () => {
|
|
137
|
+
expect(resolveAuthEnvValue('NEXTAUTH_SECRET', '', oauthKeys)).toBe(
|
|
138
|
+
'CHANGE_ME_TO_A_RANDOM_SECRET'
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('a real configured NEXTAUTH_URL / NEXTAUTH_SECRET is left untouched', () => {
|
|
143
|
+
expect(resolveAuthEnvValue('NEXTAUTH_URL', 'https://shop.example.com', oauthKeys)).toBe(
|
|
144
|
+
'https://shop.example.com'
|
|
145
|
+
)
|
|
146
|
+
expect(
|
|
147
|
+
resolveAuthEnvValue('NEXTAUTH_SECRET', 'a-real-32-char-secret-value-xxxxx', oauthKeys)
|
|
148
|
+
).toBe('a-real-32-char-secret-value-xxxxx')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('a blank NON-auth key still serializes blank — no behavior change outside the two auth keys', () => {
|
|
152
|
+
expect(resolveAuthEnvValue('TELEPORT_DB_SSL', '', oauthKeys)).toBe('')
|
|
153
|
+
expect(resolveAuthEnvValue('SOME_OTHER_KEY', '', oauthKeys)).toBe('')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
121
156
|
})
|
|
@@ -56,4 +56,32 @@ describe('generated NextAuth route — dynamic NEXTAUTH_URL', () => {
|
|
|
56
56
|
handler({ headers: { host: 'localhost:3000' } }, {})
|
|
57
57
|
expect(process.env.NEXTAUTH_URL).toBe('http://localhost:3000')
|
|
58
58
|
})
|
|
59
|
+
|
|
60
|
+
// Project a62338f9 published with `NEXTAUTH_URL=teleporthq.secrets.NEXTAUTH_URL`.
|
|
61
|
+
// The old check saw a non-empty, non-localhost string and treated it as
|
|
62
|
+
// explicitly configured, so NextAuth normalised it to
|
|
63
|
+
// `https://teleporthq.secrets.nextauth_url`, advertised that as the sign-in
|
|
64
|
+
// origin and issued `__Host-`/`__Secure-` cookies against it. Sign-in failed
|
|
65
|
+
// with a bare `credentialsSignin`. An unresolved placeholder is now treated
|
|
66
|
+
// as unset, so an already-published site self-heals on its next request.
|
|
67
|
+
it('overrides an unresolved secret placeholder on a published domain', () => {
|
|
68
|
+
process.env.NEXTAUTH_URL = 'teleporthq.secrets.NEXTAUTH_URL'
|
|
69
|
+
handler(
|
|
70
|
+
{ headers: { host: 'rare-last-dunlin.teleporthq.dev', 'x-forwarded-proto': 'https' } },
|
|
71
|
+
{}
|
|
72
|
+
)
|
|
73
|
+
expect(process.env.NEXTAUTH_URL).toBe('https://rare-last-dunlin.teleporthq.dev')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('falls back to an http origin locally, so cookies are not Secure-prefixed', () => {
|
|
77
|
+
process.env.NEXTAUTH_URL = 'teleporthq.secrets.NEXTAUTH_URL'
|
|
78
|
+
handler({ headers: { host: 'localhost:3001' } }, {})
|
|
79
|
+
expect(process.env.NEXTAUTH_URL).toBe('http://localhost:3001')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('overrides any value that is not an absolute http(s) origin', () => {
|
|
83
|
+
process.env.NEXTAUTH_URL = 'my-app.teleporthq.dev'
|
|
84
|
+
handler({ headers: { host: 'my-app.teleporthq.dev', 'x-forwarded-proto': 'https' } }, {})
|
|
85
|
+
expect(process.env.NEXTAUTH_URL).toBe('https://my-app.teleporthq.dev')
|
|
86
|
+
})
|
|
59
87
|
})
|
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/* tslint:disable:no-eval */
|
|
2
|
+
import {
|
|
3
|
+
generateAuthOptionsFile,
|
|
4
|
+
generateSessionProviderWrapper,
|
|
5
|
+
generateNextAuthUrlGuardModule,
|
|
6
|
+
buildSessionUserFields,
|
|
7
|
+
SENSITIVE_USER_FIELDS,
|
|
8
|
+
USER_REFRESH_INTERVAL_MS,
|
|
9
|
+
} from '../src/auth-generator'
|
|
10
|
+
import { accountGetCurrent } from '../src/nodes/account/account-get-current'
|
|
11
|
+
import { UIDLAuthentication, UIDLAuthTableColumn } from '@teleporthq/teleport-types'
|
|
12
|
+
|
|
13
|
+
// /api/auth/session measured ~925ms on a published deployment against ~210ms
|
|
14
|
+
// for the same route with no session cookie. The whole delta was the `jwt`
|
|
15
|
+
// callback re-reading the `users` row through a fresh unpooled pg connection on
|
|
16
|
+
// EVERY request — while `strategy: 'jwt'` exists precisely so a session costs no
|
|
17
|
+
// database round trip. On top of that, `account-get-current` fetched that
|
|
18
|
+
// endpoint again on every click that resolves the current user, serially ahead
|
|
19
|
+
// of the workflow's own request, for a session `_app`'s SessionProvider already
|
|
20
|
+
// held in memory. And the payload carried every column `SELECT *` returned,
|
|
21
|
+
// including the OAuth single-table adapter's provider tokens.
|
|
22
|
+
|
|
23
|
+
// The canonical `users` columns the GUI emits (authentication/flows/utils.ts),
|
|
24
|
+
// which the UIDL mapper then appends one column per custom account property to.
|
|
25
|
+
const CANONICAL_USERS_COLUMNS: UIDLAuthTableColumn[] = [
|
|
26
|
+
{ name: 'id', type: 'UUID', nullable: false, isPrimaryKey: true },
|
|
27
|
+
{ name: 'name', type: 'VARCHAR(255)', nullable: true },
|
|
28
|
+
{ name: 'email', type: 'VARCHAR(255)', nullable: true },
|
|
29
|
+
{ name: 'phone', type: 'VARCHAR(255)', nullable: true },
|
|
30
|
+
{ name: 'details', type: 'TEXT', nullable: true },
|
|
31
|
+
{ name: 'email_verified', type: 'TIMESTAMPTZ', nullable: true },
|
|
32
|
+
{ name: 'password', type: 'TEXT', nullable: true },
|
|
33
|
+
{ name: 'provider', type: 'VARCHAR(255)', nullable: true },
|
|
34
|
+
{ name: 'provider_account_id', type: 'VARCHAR(255)', nullable: true },
|
|
35
|
+
{ name: 'provider_type', type: 'VARCHAR(255)', nullable: true },
|
|
36
|
+
{ name: 'access_token', type: 'TEXT', nullable: true },
|
|
37
|
+
{ name: 'refresh_token', type: 'TEXT', nullable: true },
|
|
38
|
+
{ name: 'expires_at', type: 'BIGINT', nullable: true },
|
|
39
|
+
{ name: 'id_token', type: 'TEXT', nullable: true },
|
|
40
|
+
{ name: 'scope', type: 'TEXT', nullable: true },
|
|
41
|
+
{ name: 'session_state', type: 'TEXT', nullable: true },
|
|
42
|
+
{ name: 'token_type', type: 'TEXT', nullable: true },
|
|
43
|
+
{ name: 'email_unsubscribed', type: 'BOOLEAN', nullable: false },
|
|
44
|
+
{ name: 'sms_unsubscribed', type: 'BOOLEAN', nullable: false },
|
|
45
|
+
{ name: 'image', type: 'TEXT', nullable: true },
|
|
46
|
+
{ name: 'role', type: 'VARCHAR(255)', nullable: false },
|
|
47
|
+
{ name: 'created_at', type: 'TIMESTAMPTZ', nullable: false },
|
|
48
|
+
{ name: 'updated_at', type: 'TIMESTAMPTZ', nullable: false },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
const CUSTOM_PROPS = [
|
|
52
|
+
{
|
|
53
|
+
key: 'company',
|
|
54
|
+
label: 'Company',
|
|
55
|
+
columnType: 'VARCHAR(255)',
|
|
56
|
+
attributeType: 'string' as const,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: 'loyalty_points',
|
|
60
|
+
label: 'Loyalty Points',
|
|
61
|
+
columnType: 'INTEGER',
|
|
62
|
+
attributeType: 'number' as const,
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
const buildAuth = (overrides: Partial<UIDLAuthentication> = {}): UIDLAuthentication =>
|
|
67
|
+
({
|
|
68
|
+
enabled: true,
|
|
69
|
+
dataSourceId: 'ds-1',
|
|
70
|
+
dataSourceType: 'postgresql',
|
|
71
|
+
passwordAuthEnabled: true,
|
|
72
|
+
providers: [],
|
|
73
|
+
roles: ['user', 'admin'],
|
|
74
|
+
tables: {
|
|
75
|
+
users: [
|
|
76
|
+
...CANONICAL_USERS_COLUMNS,
|
|
77
|
+
{ name: 'company', type: 'VARCHAR(255)', nullable: true },
|
|
78
|
+
{ name: 'loyalty_points', type: 'INTEGER', nullable: true },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
pageProtection: {},
|
|
82
|
+
folderProtection: {},
|
|
83
|
+
authPages: {},
|
|
84
|
+
callbackBaseUrl: '/api/auth/callback',
|
|
85
|
+
envKeys: {},
|
|
86
|
+
customUserProperties: CUSTOM_PROPS,
|
|
87
|
+
...overrides,
|
|
88
|
+
} as UIDLAuthentication)
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Boots the generated auth-options file with `pg` stubbed, and reports how many
|
|
92
|
+
* `SELECT ... FROM users` queries the callbacks actually issue.
|
|
93
|
+
*/
|
|
94
|
+
const bootAuthOptions = (auth: UIDLAuthentication) => {
|
|
95
|
+
const code = generateAuthOptionsFile(auth, null)
|
|
96
|
+
const queries: string[] = []
|
|
97
|
+
|
|
98
|
+
class FakeClient {
|
|
99
|
+
public async connect(): Promise<void> {
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
public async query(text: string): Promise<{ rows: Array<Record<string, unknown>> }> {
|
|
103
|
+
queries.push(text)
|
|
104
|
+
return { rows: [{ id: 'u-1', email: 'ada@example.com', name: 'Ada', role: 'admin' }] }
|
|
105
|
+
}
|
|
106
|
+
public async end(): Promise<void> {
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fakeRequire = (name: string): any => {
|
|
112
|
+
if (name === 'pg') {
|
|
113
|
+
return { Client: FakeClient }
|
|
114
|
+
}
|
|
115
|
+
const provider = (config: unknown) => config
|
|
116
|
+
;(provider as any).default = provider
|
|
117
|
+
return provider
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const moduleObject: { exports: any } = { exports: {} }
|
|
121
|
+
// tslint:disable-next-line:function-constructor
|
|
122
|
+
new Function('require', 'module', 'exports', 'process', code)(
|
|
123
|
+
fakeRequire,
|
|
124
|
+
moduleObject,
|
|
125
|
+
moduleObject.exports,
|
|
126
|
+
{ env: { TELEPORT_DB_CONNECTION_STRING: 'postgres://stub' } }
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return { authOptions: moduleObject.exports, queries, code }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe('sanitizeUser allow-list', () => {
|
|
133
|
+
it('keeps every custom account property the project declared', () => {
|
|
134
|
+
const fields = buildSessionUserFields(buildAuth().tables, CUSTOM_PROPS)
|
|
135
|
+
expect(fields).toContain('company')
|
|
136
|
+
expect(fields).toContain('loyalty_points')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('keeps custom properties even when the UIDL carries no users table', () => {
|
|
140
|
+
const fields = buildSessionUserFields(undefined, CUSTOM_PROPS)
|
|
141
|
+
expect(fields).toContain('company')
|
|
142
|
+
expect(fields).toContain('loyalty_points')
|
|
143
|
+
expect(fields).toEqual(expect.arrayContaining(['id', 'name', 'email', 'image', 'role']))
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('keeps the non-credential profile columns pages bind to', () => {
|
|
147
|
+
const fields = buildSessionUserFields(buildAuth().tables, CUSTOM_PROPS)
|
|
148
|
+
for (const field of [
|
|
149
|
+
'id',
|
|
150
|
+
'name',
|
|
151
|
+
'email',
|
|
152
|
+
'image',
|
|
153
|
+
'role',
|
|
154
|
+
'phone',
|
|
155
|
+
'details',
|
|
156
|
+
'email_verified',
|
|
157
|
+
'email_unsubscribed',
|
|
158
|
+
'sms_unsubscribed',
|
|
159
|
+
'created_at',
|
|
160
|
+
'updated_at',
|
|
161
|
+
// Just the provider's name ("google") — account-social-login declares it.
|
|
162
|
+
'provider',
|
|
163
|
+
]) {
|
|
164
|
+
expect(fields).toContain(field)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('keeps the alternate role spellings the generated middleware falls back to', () => {
|
|
169
|
+
const fields = buildSessionUserFields(buildAuth().tables, CUSTOM_PROPS)
|
|
170
|
+
expect(fields).toContain('roleName')
|
|
171
|
+
expect(fields).toContain('roles')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('drops the password hash and every OAuth provider credential', () => {
|
|
175
|
+
const fields = buildSessionUserFields(buildAuth().tables, CUSTOM_PROPS)
|
|
176
|
+
for (const secret of SENSITIVE_USER_FIELDS) {
|
|
177
|
+
expect(fields).not.toContain(secret)
|
|
178
|
+
}
|
|
179
|
+
expect(fields).not.toContain('access_token')
|
|
180
|
+
expect(fields).not.toContain('refresh_token')
|
|
181
|
+
expect(fields).not.toContain('id_token')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('folds _id into id rather than emitting both', () => {
|
|
185
|
+
const fields = buildSessionUserFields(
|
|
186
|
+
{ users: [{ name: '_id', type: 'TEXT', nullable: false }] },
|
|
187
|
+
[]
|
|
188
|
+
)
|
|
189
|
+
expect(fields).not.toContain('_id')
|
|
190
|
+
expect(fields).toContain('id')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('cannot be re-opened by a custom property named after a credential', () => {
|
|
194
|
+
const fields = buildSessionUserFields(buildAuth().tables, [
|
|
195
|
+
{
|
|
196
|
+
key: 'access_token',
|
|
197
|
+
label: 'Access Token',
|
|
198
|
+
columnType: 'TEXT',
|
|
199
|
+
attributeType: 'string' as const,
|
|
200
|
+
},
|
|
201
|
+
])
|
|
202
|
+
expect(fields).not.toContain('access_token')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('strips credentials from a real row at runtime', () => {
|
|
206
|
+
const { authOptions } = bootAuthOptions(buildAuth())
|
|
207
|
+
const safe = (authOptions as any).sanitizeUser({
|
|
208
|
+
id: 'u-1',
|
|
209
|
+
email: 'ada@example.com',
|
|
210
|
+
name: 'Ada',
|
|
211
|
+
role: 'admin',
|
|
212
|
+
company: 'Teleport',
|
|
213
|
+
loyalty_points: 42,
|
|
214
|
+
password: '$2b$10$hash',
|
|
215
|
+
access_token: 'ya29.secret',
|
|
216
|
+
refresh_token: '1//refresh-secret',
|
|
217
|
+
id_token: 'eyJhbGciOi.secret',
|
|
218
|
+
session_state: 'state-secret',
|
|
219
|
+
scope: 'openid email',
|
|
220
|
+
token_type: 'Bearer',
|
|
221
|
+
provider_account_id: '11223344',
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
expect(safe).toEqual({
|
|
225
|
+
id: 'u-1',
|
|
226
|
+
email: 'ada@example.com',
|
|
227
|
+
name: 'Ada',
|
|
228
|
+
role: 'admin',
|
|
229
|
+
company: 'Teleport',
|
|
230
|
+
loyalty_points: 42,
|
|
231
|
+
})
|
|
232
|
+
expect(JSON.stringify(safe)).not.toContain('secret')
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('jwt callback does not re-read the database on every session request', () => {
|
|
237
|
+
it('reads the user once, then serves the token from the JWT for the interval', async () => {
|
|
238
|
+
const { authOptions, queries } = bootAuthOptions(buildAuth())
|
|
239
|
+
const token: Record<string, unknown> = { email: 'ada@example.com' }
|
|
240
|
+
|
|
241
|
+
await authOptions.callbacks.jwt({ token })
|
|
242
|
+
expect(queries.length).toBe(1)
|
|
243
|
+
|
|
244
|
+
await authOptions.callbacks.jwt({ token })
|
|
245
|
+
await authOptions.callbacks.jwt({ token })
|
|
246
|
+
await authOptions.callbacks.jwt({ token })
|
|
247
|
+
expect(queries.length).toBe(1)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('refreshes immediately on trigger "update" — a profile save is never stale', async () => {
|
|
251
|
+
const { authOptions, queries } = bootAuthOptions(buildAuth())
|
|
252
|
+
const token: Record<string, unknown> = { email: 'ada@example.com' }
|
|
253
|
+
|
|
254
|
+
await authOptions.callbacks.jwt({ token })
|
|
255
|
+
expect(queries.length).toBe(1)
|
|
256
|
+
|
|
257
|
+
await authOptions.callbacks.jwt({ token, trigger: 'update' })
|
|
258
|
+
expect(queries.length).toBe(2)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('re-reads once the interval has elapsed', async () => {
|
|
262
|
+
const { authOptions, queries } = bootAuthOptions(buildAuth())
|
|
263
|
+
const token: Record<string, unknown> = { email: 'ada@example.com' }
|
|
264
|
+
|
|
265
|
+
await authOptions.callbacks.jwt({ token })
|
|
266
|
+
expect(queries.length).toBe(1)
|
|
267
|
+
|
|
268
|
+
// Backdate the stamp past the interval instead of waiting on a real clock.
|
|
269
|
+
token.__userRefreshedAt = Date.now() - USER_REFRESH_INTERVAL_MS - 1
|
|
270
|
+
await authOptions.callbacks.jwt({ token })
|
|
271
|
+
expect(queries.length).toBe(2)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('treats a backwards clock jump as stale rather than trusting the stamp', async () => {
|
|
275
|
+
const { authOptions, queries } = bootAuthOptions(buildAuth())
|
|
276
|
+
const token: Record<string, unknown> = { email: 'ada@example.com' }
|
|
277
|
+
|
|
278
|
+
await authOptions.callbacks.jwt({ token })
|
|
279
|
+
expect(queries.length).toBe(1)
|
|
280
|
+
|
|
281
|
+
token.__userRefreshedAt = Date.now() + 60 * 60 * 1000
|
|
282
|
+
await authOptions.callbacks.jwt({ token })
|
|
283
|
+
expect(queries.length).toBe(2)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('does NOT stamp on the login branch, so an OAuth user still gets their role', async () => {
|
|
287
|
+
const { authOptions, queries } = bootAuthOptions(buildAuth())
|
|
288
|
+
// An OAuth `user` is the provider profile: no `role`, which lives only on
|
|
289
|
+
// the users row. Stamping here would leave the visitor role-less for a whole
|
|
290
|
+
// interval and silently fail role-protected routes.
|
|
291
|
+
const token: Record<string, unknown> = {}
|
|
292
|
+
await authOptions.callbacks.jwt({
|
|
293
|
+
token,
|
|
294
|
+
user: { id: 'u-1', email: 'ada@example.com', name: 'Ada', image: null },
|
|
295
|
+
})
|
|
296
|
+
expect(queries.length).toBe(0)
|
|
297
|
+
expect(token.__userRefreshedAt).toBeUndefined()
|
|
298
|
+
|
|
299
|
+
await authOptions.callbacks.jwt({ token })
|
|
300
|
+
expect(queries.length).toBe(1)
|
|
301
|
+
expect(token.role).toBe('admin')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('costs one attempt per interval — not one per request — when the database is down', async () => {
|
|
305
|
+
const auth = buildAuth()
|
|
306
|
+
const code = generateAuthOptionsFile(auth, null)
|
|
307
|
+
let attempts = 0
|
|
308
|
+
|
|
309
|
+
class BrokenClient {
|
|
310
|
+
public async connect(): Promise<void> {
|
|
311
|
+
attempts += 1
|
|
312
|
+
throw new Error('ECONNREFUSED')
|
|
313
|
+
}
|
|
314
|
+
public async query(): Promise<{ rows: Array<Record<string, unknown>> }> {
|
|
315
|
+
return { rows: [] }
|
|
316
|
+
}
|
|
317
|
+
public async end(): Promise<void> {
|
|
318
|
+
return undefined
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const fakeRequire = (name: string): any => {
|
|
323
|
+
if (name === 'pg') {
|
|
324
|
+
return { Client: BrokenClient }
|
|
325
|
+
}
|
|
326
|
+
const provider = (config: unknown) => config
|
|
327
|
+
;(provider as any).default = provider
|
|
328
|
+
return provider
|
|
329
|
+
}
|
|
330
|
+
const moduleObject: { exports: any } = { exports: {} }
|
|
331
|
+
// tslint:disable-next-line:function-constructor
|
|
332
|
+
new Function('require', 'module', 'exports', 'process', code)(
|
|
333
|
+
fakeRequire,
|
|
334
|
+
moduleObject,
|
|
335
|
+
moduleObject.exports,
|
|
336
|
+
{ env: { TELEPORT_DB_CONNECTION_STRING: 'postgres://stub' } }
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
const token: Record<string, unknown> = { email: 'ada@example.com' }
|
|
340
|
+
await moduleObject.exports.callbacks.jwt({ token })
|
|
341
|
+
await moduleObject.exports.callbacks.jwt({ token })
|
|
342
|
+
await moduleObject.exports.callbacks.jwt({ token })
|
|
343
|
+
|
|
344
|
+
expect(attempts).toBe(1)
|
|
345
|
+
// Never signs the user out on a DB hiccup.
|
|
346
|
+
expect(token.email).toBe('ada@example.com')
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('never copies the refresh bookkeeping onto session.user', async () => {
|
|
350
|
+
const { authOptions } = bootAuthOptions(buildAuth())
|
|
351
|
+
const token: Record<string, unknown> = { email: 'ada@example.com' }
|
|
352
|
+
await authOptions.callbacks.jwt({ token })
|
|
353
|
+
expect(token.__userRefreshedAt).toEqual(expect.any(Number))
|
|
354
|
+
|
|
355
|
+
const session = await authOptions.callbacks.session({ session: { user: {} }, token })
|
|
356
|
+
expect(session.user.__userRefreshedAt).toBeUndefined()
|
|
357
|
+
expect(session.user.iat).toBeUndefined()
|
|
358
|
+
expect(session.user.exp).toBeUndefined()
|
|
359
|
+
expect(session.user.email).toBe('ada@example.com')
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
describe('nextauth-url guard prevents the empty-NEXTAUTH_URL SSR crash', () => {
|
|
364
|
+
// next-auth v4 `react/index.js` calls `parseUrl(process.env.NEXTAUTH_URL)` at
|
|
365
|
+
// MODULE LOAD; `new URL('')` throws "Invalid URL", crashing SSR for every page
|
|
366
|
+
// that mounts SessionProvider. The guard runs first and normalizes the value.
|
|
367
|
+
const guard = generateNextAuthUrlGuardModule()
|
|
368
|
+
|
|
369
|
+
// Execute the guard body against a stubbed process, the way it runs at import.
|
|
370
|
+
const runGuard = (
|
|
371
|
+
env: Record<string, string | undefined>
|
|
372
|
+
): Record<string, string | undefined> => {
|
|
373
|
+
const body = guard.replace(/export\s*\{\s*\}\s*;?\s*$/, '')
|
|
374
|
+
const stubProcess = { env }
|
|
375
|
+
// tslint:disable-next-line:function-constructor
|
|
376
|
+
new Function('process', body)(stubProcess)
|
|
377
|
+
return env
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
it('deletes an empty NEXTAUTH_URL so next-auth falls back to its default', () => {
|
|
381
|
+
const env: Record<string, string | undefined> = { NEXTAUTH_URL: '' }
|
|
382
|
+
runGuard(env)
|
|
383
|
+
expect('NEXTAUTH_URL' in env).toBe(false)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('deletes a whitespace-only NEXTAUTH_URL', () => {
|
|
387
|
+
const env: Record<string, string | undefined> = { NEXTAUTH_URL: ' ' }
|
|
388
|
+
runGuard(env)
|
|
389
|
+
expect('NEXTAUTH_URL' in env).toBe(false)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('also normalizes empty NEXTAUTH_URL_INTERNAL and VERCEL_URL', () => {
|
|
393
|
+
const env: Record<string, string | undefined> = {
|
|
394
|
+
NEXTAUTH_URL_INTERNAL: '',
|
|
395
|
+
VERCEL_URL: ' ',
|
|
396
|
+
}
|
|
397
|
+
runGuard(env)
|
|
398
|
+
expect('NEXTAUTH_URL_INTERNAL' in env).toBe(false)
|
|
399
|
+
expect('VERCEL_URL' in env).toBe(false)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('leaves a real configured NEXTAUTH_URL untouched', () => {
|
|
403
|
+
const env: Record<string, string | undefined> = { NEXTAUTH_URL: 'https://shop.example.com' }
|
|
404
|
+
runGuard(env)
|
|
405
|
+
expect(env.NEXTAUTH_URL).toBe('https://shop.example.com')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('leaves an UNSET NEXTAUTH_URL unset (never introduces a bogus value)', () => {
|
|
409
|
+
const env: Record<string, string | undefined> = {}
|
|
410
|
+
runGuard(env)
|
|
411
|
+
expect('NEXTAUTH_URL' in env).toBe(false)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('a stubbed next-auth/react module-load pattern no longer throws after the guard runs', () => {
|
|
415
|
+
// Mirror of next-auth/react's module-scope `parseUrl(process.env.NEXTAUTH_URL)`
|
|
416
|
+
// (parse-url.js: `new URL(url ?? default)` — empty string is NOT nullish, so
|
|
417
|
+
// it reaches new URL and throws).
|
|
418
|
+
const nextAuthModuleLoad = (processEnv: Record<string, string | undefined>) => {
|
|
419
|
+
const url = processEnv.NEXTAUTH_URL
|
|
420
|
+
// eslint-disable-next-line no-new
|
|
421
|
+
new URL(url != null ? url : 'http://localhost:3000/api/auth')
|
|
422
|
+
}
|
|
423
|
+
const env: Record<string, string | undefined> = { NEXTAUTH_URL: '' }
|
|
424
|
+
expect(() => nextAuthModuleLoad(env)).toThrow() // reproduces the crash
|
|
425
|
+
runGuard(env)
|
|
426
|
+
expect(() => nextAuthModuleLoad(env)).not.toThrow() // fixed by the guard
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
describe('session provider republishes the in-memory session', () => {
|
|
431
|
+
const code = generateSessionProviderWrapper()
|
|
432
|
+
|
|
433
|
+
it('imports the nextauth-url guard FIRST, before next-auth/react', () => {
|
|
434
|
+
const guardIdx = code.indexOf("import './nextauth-url-guard'")
|
|
435
|
+
const nextAuthIdx = code.indexOf("from 'next-auth/react'")
|
|
436
|
+
expect(guardIdx).toBeGreaterThanOrEqual(0)
|
|
437
|
+
expect(nextAuthIdx).toBeGreaterThan(guardIdx)
|
|
438
|
+
// It must be the very first non-empty line so nothing imports next-auth/react
|
|
439
|
+
// before it in the module graph.
|
|
440
|
+
expect(code.trimStart().startsWith("import './nextauth-url-guard'")).toBe(true)
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('subscribes to the session context from inside SessionProvider', () => {
|
|
444
|
+
expect(code).toContain(
|
|
445
|
+
"import { SessionProvider, signIn, signOut, useSession } from 'next-auth/react'"
|
|
446
|
+
)
|
|
447
|
+
expect(code).toContain('function SessionSnapshotBridge()')
|
|
448
|
+
expect(code).toContain('React.createElement(SessionSnapshotBridge')
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('exposes getSession and refreshSession on the existing window bridge', () => {
|
|
452
|
+
expect(code).toContain('window.__teleportNextAuth = teleportNextAuth')
|
|
453
|
+
expect(code).toContain('getSession: function ()')
|
|
454
|
+
expect(code).toContain('refreshSession: function ()')
|
|
455
|
+
// The bridge object is assigned to window ONCE and mutated in place, so a
|
|
456
|
+
// handler that captured it earlier still observes the current session.
|
|
457
|
+
expect(code).toContain('teleportNextAuth.status = status')
|
|
458
|
+
expect(code).toContain('teleportNextAuth.session = data || null')
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('stops refetching the session on every window focus', () => {
|
|
462
|
+
expect(code).toContain('refetchOnWindowFocus: false')
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('mirrors in an effect, never during render', () => {
|
|
466
|
+
expect(code).toContain('React.useEffect(')
|
|
467
|
+
const renderBody = code.slice(code.indexOf('function SessionSnapshotBridge()'))
|
|
468
|
+
expect(renderBody.indexOf('React.useEffect(')).toBeLessThan(renderBody.indexOf('return null'))
|
|
469
|
+
})
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
describe('account-get-current reads the in-memory session before the network', () => {
|
|
473
|
+
const evalHandler = (): any => eval('(' + accountGetCurrent.generateHandler() + ')')
|
|
474
|
+
|
|
475
|
+
const withWindow = async (
|
|
476
|
+
bridge: unknown,
|
|
477
|
+
fetchImpl: () => Promise<unknown>,
|
|
478
|
+
run: (handler: any) => Promise<void>
|
|
479
|
+
) => {
|
|
480
|
+
const store: Record<string, string> = {}
|
|
481
|
+
;(global as any).window = {
|
|
482
|
+
__teleportNextAuth: bridge,
|
|
483
|
+
localStorage: {
|
|
484
|
+
getItem: (key: string) => (key in store ? store[key] : null),
|
|
485
|
+
setItem: (key: string, value: string) => {
|
|
486
|
+
store[key] = value
|
|
487
|
+
},
|
|
488
|
+
removeItem: (key: string) => {
|
|
489
|
+
delete store[key]
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
}
|
|
493
|
+
;(global as any).fetch = fetchImpl
|
|
494
|
+
try {
|
|
495
|
+
await run(evalHandler())
|
|
496
|
+
} finally {
|
|
497
|
+
delete (global as any).window
|
|
498
|
+
delete (global as any).fetch
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const authenticatedBridge = (user: unknown) => ({
|
|
503
|
+
getSession: () => ({ status: 'authenticated', session: { user } }),
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('returns the signed-in user without touching /api/auth/session', async () => {
|
|
507
|
+
const user = { id: 'u-1', email: 'ada@example.com', name: 'Ada', role: 'admin' }
|
|
508
|
+
let fetched = 0
|
|
509
|
+
await withWindow(
|
|
510
|
+
authenticatedBridge(user),
|
|
511
|
+
async () => {
|
|
512
|
+
fetched += 1
|
|
513
|
+
return { ok: true, json: async () => ({ user }) }
|
|
514
|
+
},
|
|
515
|
+
async (handler) => {
|
|
516
|
+
const out = await handler({}, {})
|
|
517
|
+
expect(fetched).toBe(0)
|
|
518
|
+
// Same contract as the network path: flat fields AND `.user`.
|
|
519
|
+
expect(out.id).toBe('u-1')
|
|
520
|
+
expect(out.email).toBe('ada@example.com')
|
|
521
|
+
expect(out.user).toEqual(user)
|
|
522
|
+
}
|
|
523
|
+
)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('still fetches while the provider is loading', async () => {
|
|
527
|
+
const user = { id: 'u-2', email: 'grace@example.com' }
|
|
528
|
+
let fetched = 0
|
|
529
|
+
await withWindow(
|
|
530
|
+
{ getSession: () => ({ status: 'loading', session: null as unknown }) },
|
|
531
|
+
async () => {
|
|
532
|
+
fetched += 1
|
|
533
|
+
return { ok: true, json: async () => ({ user }) }
|
|
534
|
+
},
|
|
535
|
+
async (handler) => {
|
|
536
|
+
const out = await handler({}, {})
|
|
537
|
+
expect(fetched).toBe(1)
|
|
538
|
+
expect(out.id).toBe('u-2')
|
|
539
|
+
}
|
|
540
|
+
)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('still fetches when the provider reports unauthenticated', async () => {
|
|
544
|
+
// next-auth also reports 'unauthenticated' when its own fetch FAILED, so
|
|
545
|
+
// trusting it would send a signed-in visitor down the guest branch.
|
|
546
|
+
const user = { id: 'u-3', email: 'alan@example.com' }
|
|
547
|
+
let fetched = 0
|
|
548
|
+
await withWindow(
|
|
549
|
+
{ getSession: () => ({ status: 'unauthenticated', session: null as unknown }) },
|
|
550
|
+
async () => {
|
|
551
|
+
fetched += 1
|
|
552
|
+
return { ok: true, json: async () => ({ user }) }
|
|
553
|
+
},
|
|
554
|
+
async (handler) => {
|
|
555
|
+
const out = await handler({}, {})
|
|
556
|
+
expect(fetched).toBe(1)
|
|
557
|
+
expect(out.id).toBe('u-3')
|
|
558
|
+
}
|
|
559
|
+
)
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('falls back to the network when no bridge is published at all', async () => {
|
|
563
|
+
const user = { id: 'u-4', email: 'edsger@example.com' }
|
|
564
|
+
let fetched = 0
|
|
565
|
+
await withWindow(
|
|
566
|
+
undefined,
|
|
567
|
+
async () => {
|
|
568
|
+
fetched += 1
|
|
569
|
+
return { ok: true, json: async () => ({ user }) }
|
|
570
|
+
},
|
|
571
|
+
async (handler) => {
|
|
572
|
+
const out = await handler({}, {})
|
|
573
|
+
expect(fetched).toBe(1)
|
|
574
|
+
expect(out.id).toBe('u-4')
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('mirrors the in-memory user into localStorage for the offline fallback', async () => {
|
|
580
|
+
const user = { id: 'u-5', email: 'barbara@example.com' }
|
|
581
|
+
await withWindow(
|
|
582
|
+
authenticatedBridge(user),
|
|
583
|
+
async () => {
|
|
584
|
+
throw new Error('network is down')
|
|
585
|
+
},
|
|
586
|
+
async (handler) => {
|
|
587
|
+
await handler({}, {})
|
|
588
|
+
const cached = (global as any).window.localStorage.getItem('teleport_auth_user')
|
|
589
|
+
expect(JSON.parse(cached)).toEqual(user)
|
|
590
|
+
}
|
|
591
|
+
)
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
it('ignores a malformed bridge rather than throwing', async () => {
|
|
595
|
+
const user = { id: 'u-6', email: 'ken@example.com' }
|
|
596
|
+
let fetched = 0
|
|
597
|
+
await withWindow(
|
|
598
|
+
{
|
|
599
|
+
getSession: () => {
|
|
600
|
+
throw new Error('bridge exploded')
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
async () => {
|
|
604
|
+
fetched += 1
|
|
605
|
+
return { ok: true, json: async () => ({ user }) }
|
|
606
|
+
},
|
|
607
|
+
async (handler) => {
|
|
608
|
+
const out = await handler({}, {})
|
|
609
|
+
expect(fetched).toBe(1)
|
|
610
|
+
expect(out.id).toBe('u-6')
|
|
611
|
+
}
|
|
612
|
+
)
|
|
613
|
+
})
|
|
614
|
+
})
|