@biffo/cli 0.215.1 → 0.215.2
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.
|
@@ -13,9 +13,9 @@ process.env['NEXT_PUBLIC_SIBLING_PATH_PREFIX'] = '/my-sibling'
|
|
|
13
13
|
const { getCurrentSession } = await import('@/lib/auth')
|
|
14
14
|
const { AuthGate } = await import('./auth-gate')
|
|
15
15
|
|
|
16
|
-
function fakeSession(idToken: string) {
|
|
16
|
+
function fakeSession(idToken: string, claims: Record<string, unknown> = {}) {
|
|
17
17
|
return {
|
|
18
|
-
getIdToken: () => ({ getJwtToken: () => idToken }),
|
|
18
|
+
getIdToken: () => ({ getJwtToken: () => idToken, decodePayload: () => claims }),
|
|
19
19
|
} as unknown as Awaited<ReturnType<typeof getCurrentSession>>
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -81,3 +81,146 @@ describe('AuthGate', () => {
|
|
|
81
81
|
})
|
|
82
82
|
})
|
|
83
83
|
})
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A session is not an authorisation.
|
|
87
|
+
*
|
|
88
|
+
* `AuthGate` used to answer one question — is anybody signed in? — so every
|
|
89
|
+
* sibling that needed "signed in AND allowed" hand-rolled the second half.
|
|
90
|
+
* The core portal's own guard had the identical hole and shipped it: any
|
|
91
|
+
* authenticated user rendered the whole /admin console (biffo-template#1104).
|
|
92
|
+
* A sibling that hand-rolls it gets it wrong differently each time; one such
|
|
93
|
+
* gate very nearly shipped gating completion reports on an *authoring*
|
|
94
|
+
* permission, which would have refused precisely the oversight roles the
|
|
95
|
+
* reports exist for.
|
|
96
|
+
*
|
|
97
|
+
* So the second question is asked here, once, with one refusal surface.
|
|
98
|
+
*/
|
|
99
|
+
describe('AuthGate authorisation', () => {
|
|
100
|
+
it('renders children when the session carries one of the required groups', async () => {
|
|
101
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce(
|
|
102
|
+
fakeSession('a-jwt', { 'cognito:groups': ['editor'] }),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
render(
|
|
106
|
+
<AuthGate requireGroups={['admin', 'editor']}>
|
|
107
|
+
<p>secret content</p>
|
|
108
|
+
</AuthGate>,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
await waitFor(() => {
|
|
112
|
+
expect(screen.getByText('secret content')).toBeInTheDocument()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('refuses a signed-in user whose token has no groups claim at all', async () => {
|
|
117
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce(fakeSession('a-jwt'))
|
|
118
|
+
|
|
119
|
+
render(
|
|
120
|
+
<AuthGate requireGroups={['admin']}>
|
|
121
|
+
<p>secret content</p>
|
|
122
|
+
</AuthGate>,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
await waitFor(() => {
|
|
126
|
+
expect(screen.getByRole('heading')).toHaveTextContent('No access')
|
|
127
|
+
})
|
|
128
|
+
expect(screen.queryByText('secret content')).not.toBeInTheDocument()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('does not bounce a refused user to the portal login — they are already signed in', async () => {
|
|
132
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce(fakeSession('a-jwt'))
|
|
133
|
+
|
|
134
|
+
const originalLocation = window.location
|
|
135
|
+
Object.defineProperty(window, 'location', {
|
|
136
|
+
value: { href: '', pathname: '/my-sibling/reports/', search: '' },
|
|
137
|
+
writable: true,
|
|
138
|
+
configurable: true,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
render(
|
|
142
|
+
<AuthGate requireGroups={['admin']}>
|
|
143
|
+
<p>secret content</p>
|
|
144
|
+
</AuthGate>,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
expect(screen.getByRole('heading')).toHaveTextContent('No access')
|
|
149
|
+
})
|
|
150
|
+
expect(window.location.href).toBe('')
|
|
151
|
+
|
|
152
|
+
Object.defineProperty(window, 'location', {
|
|
153
|
+
value: originalLocation,
|
|
154
|
+
writable: true,
|
|
155
|
+
configurable: true,
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('honours an `authorize` predicate for anything groups cannot express', async () => {
|
|
160
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce(
|
|
161
|
+
fakeSession('a-jwt', { 'custom:permissions': 'enrollments.read' }),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
render(
|
|
165
|
+
<AuthGate
|
|
166
|
+
authorize={(session) =>
|
|
167
|
+
session.getIdToken().decodePayload()['custom:permissions'] === 'enrollments.read'
|
|
168
|
+
}
|
|
169
|
+
>
|
|
170
|
+
<p>secret content</p>
|
|
171
|
+
</AuthGate>,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
await waitFor(() => {
|
|
175
|
+
expect(screen.getByText('secret content')).toBeInTheDocument()
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('shows the caller-supplied refusal message rather than the generic one', async () => {
|
|
180
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce(fakeSession('a-jwt'))
|
|
181
|
+
|
|
182
|
+
render(
|
|
183
|
+
<AuthGate requireGroups={['admin']} noAccessMessage="You don't have access to reports.">
|
|
184
|
+
<p>secret content</p>
|
|
185
|
+
</AuthGate>,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
await waitFor(() => {
|
|
189
|
+
expect(screen.getByText("You don't have access to reports.")).toBeInTheDocument()
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('fails closed when the ID token cannot be decoded', async () => {
|
|
194
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce({
|
|
195
|
+
getIdToken: () => {
|
|
196
|
+
throw new Error('malformed token')
|
|
197
|
+
},
|
|
198
|
+
} as unknown as Awaited<ReturnType<typeof getCurrentSession>>)
|
|
199
|
+
|
|
200
|
+
render(
|
|
201
|
+
<AuthGate requireGroups={['admin']}>
|
|
202
|
+
<p>secret content</p>
|
|
203
|
+
</AuthGate>,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
await waitFor(() => {
|
|
207
|
+
expect(screen.getByRole('heading')).toHaveTextContent('No access')
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('still yields on the session alone when nothing is required', async () => {
|
|
212
|
+
// Public-by-default is the sibling contract; asking only for a session
|
|
213
|
+
// stays a legitimate, and common, thing to want.
|
|
214
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce(fakeSession('a-jwt'))
|
|
215
|
+
|
|
216
|
+
render(
|
|
217
|
+
<AuthGate>
|
|
218
|
+
<p>secret content</p>
|
|
219
|
+
</AuthGate>,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
await waitFor(() => {
|
|
223
|
+
expect(screen.getByText('secret content')).toBeInTheDocument()
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
})
|
|
@@ -20,6 +20,14 @@ import { getCurrentSession } from '@/lib/auth'
|
|
|
20
20
|
// to the portal's login with `return_to` pointing back at the exact page they
|
|
21
21
|
// were trying to reach, so they land right back here once signed in.
|
|
22
22
|
//
|
|
23
|
+
// A SESSION IS NOT AN AUTHORISATION. "Somebody is signed in" and "this person
|
|
24
|
+
// is allowed here" are different questions, and this gate answers both: pass
|
|
25
|
+
// `requireGroups` and/or `authorize` for the second one. Ask for it explicitly
|
|
26
|
+
// on any page whose content is not for every signed-in user — a wrapper that
|
|
27
|
+
// only checked for a session is exactly how the core portal shipped its whole
|
|
28
|
+
// /admin console to any authenticated visitor (biffo-template#1104), and how
|
|
29
|
+
// each sibling that hand-rolled the missing half got it wrong differently.
|
|
30
|
+
//
|
|
23
31
|
// The session it yields carries the ID token you pass to THIS sibling's own
|
|
24
32
|
// backend (createApiClient in ./api-client.ts) — never to the core API
|
|
25
33
|
// directly (ADR-0002). The backend re-verifies that JWT itself.
|
|
@@ -31,6 +39,35 @@ const SIBLING_PATH_PREFIX = process.env['NEXT_PUBLIC_SIBLING_PATH_PREFIX'] ?? ''
|
|
|
31
39
|
type GateState =
|
|
32
40
|
{ kind: 'checking' } | { kind: 'redirecting' } | { kind: 'authed'; session: CognitoUserSession }
|
|
33
41
|
|
|
42
|
+
/** The default refusal wording, used when a caller supplies none of its own. */
|
|
43
|
+
const DEFAULT_NO_ACCESS_MESSAGE =
|
|
44
|
+
"Your account doesn't have access to this page. Contact your administrator if you think that's wrong."
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The caller's Cognito groups, or `[]`.
|
|
48
|
+
*
|
|
49
|
+
* Fails CLOSED in every degenerate case — no `cognito:groups` claim (the
|
|
50
|
+
* ordinary state of a user in no groups), a claim that is not an array of
|
|
51
|
+
* strings, or a token that cannot be decoded at all. An empty list satisfies
|
|
52
|
+
* no requirement, so a malformed token is refused rather than waved through:
|
|
53
|
+
* the alternative is a gate whose bypass is "present a token it cannot parse".
|
|
54
|
+
*
|
|
55
|
+
* Exported because a sibling's own code often needs the same reading — do not
|
|
56
|
+
* re-derive it, and do not treat it as authorisation. The core API re-verifies
|
|
57
|
+
* the JWT and applies the real scoping on every forwarded call (ADR-0002);
|
|
58
|
+
* this only decides what the UI offers.
|
|
59
|
+
*/
|
|
60
|
+
export function sessionGroups(session: CognitoUserSession): string[] {
|
|
61
|
+
let claim: unknown
|
|
62
|
+
try {
|
|
63
|
+
claim = session.getIdToken().decodePayload()['cognito:groups']
|
|
64
|
+
} catch {
|
|
65
|
+
return []
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(claim)) return []
|
|
68
|
+
return claim.filter((group): group is string => typeof group === 'string')
|
|
69
|
+
}
|
|
70
|
+
|
|
34
71
|
/**
|
|
35
72
|
* The URL to send an unauthenticated visitor back to after they log in.
|
|
36
73
|
*
|
|
@@ -44,6 +81,32 @@ function currentReturnTo(): string {
|
|
|
44
81
|
return `${SIBLING_PATH_PREFIX}/`
|
|
45
82
|
}
|
|
46
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Whether a resolved session satisfies the gate's requirements.
|
|
86
|
+
*
|
|
87
|
+
* Both conditions must hold, and an `authorize` predicate that throws counts
|
|
88
|
+
* as a refusal — same reasoning as `sessionGroups`: the failure mode of an
|
|
89
|
+
* authorisation check must never be "allowed".
|
|
90
|
+
*/
|
|
91
|
+
function permitted(
|
|
92
|
+
session: CognitoUserSession,
|
|
93
|
+
requireGroups: string[] | undefined,
|
|
94
|
+
authorize: ((session: CognitoUserSession) => boolean) | undefined,
|
|
95
|
+
): boolean {
|
|
96
|
+
if (requireGroups !== undefined && requireGroups.length > 0) {
|
|
97
|
+
const held = sessionGroups(session)
|
|
98
|
+
if (!requireGroups.some((group) => held.includes(group))) return false
|
|
99
|
+
}
|
|
100
|
+
if (authorize !== undefined) {
|
|
101
|
+
try {
|
|
102
|
+
if (!authorize(session)) return false
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
47
110
|
type AuthGateProps = {
|
|
48
111
|
/**
|
|
49
112
|
* What to render once a valid session exists. Either plain nodes, or a
|
|
@@ -58,6 +121,30 @@ type AuthGateProps = {
|
|
|
58
121
|
* Defaults to a centered spinner (see globals.css).
|
|
59
122
|
*/
|
|
60
123
|
fallback?: ReactNode
|
|
124
|
+
/**
|
|
125
|
+
* Cognito groups, ANY of which admits the caller. Omitting this means "any
|
|
126
|
+
* signed-in user" — a real and common answer, but make it a decision rather
|
|
127
|
+
* than an oversight.
|
|
128
|
+
*/
|
|
129
|
+
requireGroups?: string[]
|
|
130
|
+
/**
|
|
131
|
+
* Anything group membership cannot express: a permission claim, a role
|
|
132
|
+
* fetched from this sibling's own backend and closed over, a tenant match.
|
|
133
|
+
* Applied in addition to `requireGroups`; both must pass.
|
|
134
|
+
*
|
|
135
|
+
* Name the predicate after what the PAGE needs, not after the nearest role
|
|
136
|
+
* you already have a helper for. A completion report is for whoever may read
|
|
137
|
+
* enrolments — gating it on an authoring permission refuses exactly the
|
|
138
|
+
* oversight roles it is built for, and that one nearly shipped.
|
|
139
|
+
*/
|
|
140
|
+
authorize?: (session: CognitoUserSession) => boolean
|
|
141
|
+
/**
|
|
142
|
+
* Refusal wording, in this product's voice. Say what the page was, not what
|
|
143
|
+
* the check was: "You don't have access to training reports."
|
|
144
|
+
*/
|
|
145
|
+
noAccessMessage?: string
|
|
146
|
+
/** Replace the whole refusal surface, when a message is not enough. */
|
|
147
|
+
noAccess?: ReactNode
|
|
61
148
|
}
|
|
62
149
|
|
|
63
150
|
/**
|
|
@@ -74,8 +161,25 @@ type AuthGateProps = {
|
|
|
74
161
|
*
|
|
75
162
|
* No session → the visitor is redirected to the core portal's login and
|
|
76
163
|
* returned here afterwards. Valid session → `children` render.
|
|
164
|
+
*
|
|
165
|
+
* And when the page is not for every signed-in user, say so:
|
|
166
|
+
*
|
|
167
|
+
* <AuthGate requireGroups={['admin']} noAccessMessage="You don't have access to reports.">
|
|
168
|
+
* <Reports />
|
|
169
|
+
* </AuthGate>
|
|
170
|
+
*
|
|
171
|
+
* A refused caller is shown the no-access surface, NOT redirected: they are
|
|
172
|
+
* already signed in, so sending them to a login page either loops them
|
|
173
|
+
* straight back or strands them on a form that cannot help.
|
|
77
174
|
*/
|
|
78
|
-
export function AuthGate({
|
|
175
|
+
export function AuthGate({
|
|
176
|
+
children,
|
|
177
|
+
fallback,
|
|
178
|
+
requireGroups,
|
|
179
|
+
authorize,
|
|
180
|
+
noAccessMessage,
|
|
181
|
+
noAccess,
|
|
182
|
+
}: AuthGateProps) {
|
|
79
183
|
const [state, setState] = useState<GateState>({ kind: 'checking' })
|
|
80
184
|
|
|
81
185
|
useEffect(() => {
|
|
@@ -106,5 +210,21 @@ export function AuthGate({ children, fallback }: AuthGateProps) {
|
|
|
106
210
|
)
|
|
107
211
|
}
|
|
108
212
|
|
|
213
|
+
// Authorisation is decided during render, not stored in `state`, so a caller
|
|
214
|
+
// may pass a fresh array or arrow literal on every render without either
|
|
215
|
+
// re-running the session fetch or needing a dependency-array escape hatch.
|
|
216
|
+
if (!permitted(state.session, requireGroups, authorize)) {
|
|
217
|
+
return (
|
|
218
|
+
noAccess ?? (
|
|
219
|
+
<main className="center-screen">
|
|
220
|
+
<div>
|
|
221
|
+
<h1>No access</h1>
|
|
222
|
+
<p>{noAccessMessage ?? DEFAULT_NO_ACCESS_MESSAGE}</p>
|
|
223
|
+
</div>
|
|
224
|
+
</main>
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
109
229
|
return <>{typeof children === 'function' ? children(state.session) : children}</>
|
|
110
230
|
}
|