@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({ children, fallback }: AuthGateProps) {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.215.1",
3
+ "version": "0.215.2",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",