-
Notifications
You must be signed in to change notification settings - Fork 4
feat(security): let a consumer supply the access token via setAccessTokenResolver #329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
gcutrini
wants to merge
5
commits into
main
Choose a base branch
from
feat/access-token-resolver-5x
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
ae05306
feat(security): let a consumer supply the access token via setAccessT…
gcutrini 458fb39
test(security): cover setAccessTokenResolver / getAccessToken delegation
gcutrini 83d3a4e
fix(query-actions): report a token failure only through the callback
gcutrini cfd9def
test(security): cover the access-token resolver at its internal callers
gcutrini eb08c62
refactor(security): share the access-token resolver through a Symbol.…
gcutrini File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| /** | ||
| * AttendanceTracker reads the access token through security/methods.getAccessToken, | ||
| * so a registered resolver must be what the metrics calls send. | ||
| * superagent is mocked; the token path is real. | ||
| */ | ||
| import React from "react"; | ||
| import { render, act } from "@testing-library/react"; | ||
| import http from "superagent/lib/client"; | ||
| import { setAccessTokenResolver } from "../security/methods"; | ||
| import AttendanceTracker from "../attendance-tracker"; | ||
|
|
||
| jest.mock("superagent/lib/client", () => { | ||
| const end = jest.fn(); | ||
| const send = jest.fn(() => ({ end })); | ||
| return { put: jest.fn(() => ({ send })), post: jest.fn(() => ({ send })), __send: send }; | ||
| }); | ||
|
|
||
| const flush = async () => { | ||
| for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0)); | ||
| }; | ||
|
|
||
| describe("AttendanceTracker with an access-token resolver", () => { | ||
| const props = { apiBaseUrl: "https://api.test", summitId: 13, sourceId: 7, sourceName: "EVENT" }; | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| navigator.sendBeacon = jest.fn(); | ||
| }); | ||
|
|
||
| afterEach(() => setAccessTokenResolver(null)); | ||
|
|
||
| test("enter, leave and the unload beacon all send the resolver's token", async () => { | ||
| const resolver = jest.fn(() => Promise.resolve("RES-TOK")); | ||
| setAccessTokenResolver(resolver); | ||
|
|
||
| const { unmount } = render(<AttendanceTracker {...props} />); | ||
| await act(flush); | ||
| expect(http.put).toHaveBeenCalledWith("https://api.test/api/v1/summits/13/metrics/enter"); | ||
| expect(http.__send).toHaveBeenCalledWith( | ||
| expect.objectContaining({ access_token: "RES-TOK", type: "EVENT", source_id: 7 }) | ||
| ); | ||
|
|
||
| await act(async () => { | ||
| window.dispatchEvent(new Event("beforeunload")); | ||
| await flush(); | ||
| }); | ||
| const beaconUrl = navigator.sendBeacon.mock.calls[0][0]; | ||
| expect(beaconUrl).toContain("/api/v1/summits/13/metrics/leave?access_token=RES-TOK"); | ||
|
|
||
| jest.clearAllMocks(); | ||
| unmount(); | ||
| await act(flush); | ||
| expect(http.post).toHaveBeenCalledWith("https://api.test/api/v1/summits/13/metrics/leave"); | ||
| expect(http.__send).toHaveBeenCalledWith(expect.objectContaining({ access_token: "RES-TOK" })); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| /** | ||
| * getUserInfo reads the access token through security/methods.getAccessToken, | ||
| * so a registered resolver must be what the /members/me request sends. | ||
| * The request pipeline (utils/actions) is mocked; the token path is real. | ||
| */ | ||
| jest.mock("../../../utils/actions", () => ({ | ||
| getRequest: jest.fn(), | ||
| createAction: jest.fn((t) => ({ type: t })), | ||
| authErrorHandler: jest.fn(), | ||
| showMessage: jest.fn(() => () => {}), | ||
| startLoading: jest.fn(() => ({ type: "START_LOADING" })), | ||
| stopLoading: jest.fn(() => ({ type: "STOP_LOADING" })), | ||
| })); | ||
| jest.mock("../../../utils/methods", () => ({ | ||
| buildAPIBaseUrl: jest.fn((p) => `BASE${p}`), | ||
| getAllowedUserGroups: jest.fn(() => ""), | ||
| })); | ||
|
|
||
| import { getUserInfo } from "../actions"; | ||
| import { setAccessTokenResolver } from "../methods"; | ||
| import { getRequest } from "../../../utils/actions"; | ||
|
|
||
| describe("getUserInfo with an access-token resolver", () => { | ||
| afterEach(() => { | ||
| setAccessTokenResolver(null); | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| test("sends the resolver's token to /members/me", async () => { | ||
| const resolver = jest.fn(() => Promise.resolve("RES-TOK")); | ||
| setAccessTokenResolver(resolver); | ||
| const withParams = jest.fn(() => jest.fn(() => Promise.resolve())); | ||
| getRequest.mockReturnValue(withParams); | ||
| const dispatch = jest.fn(); | ||
| const getState = jest.fn(() => ({ loggedUserState: { member: null } })); | ||
|
|
||
| await getUserInfo("groups", "", null, null, null)(dispatch, getState); | ||
|
|
||
| expect(resolver).toHaveBeenCalled(); | ||
| expect(getRequest).toHaveBeenCalledWith( | ||
| expect.anything(), | ||
| expect.anything(), | ||
| "BASE/api/v1/members/me", | ||
| expect.anything() | ||
| ); | ||
| expect(withParams).toHaveBeenCalledWith( | ||
| expect.objectContaining({ access_token: "RES-TOK", expand: "groups" }) | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| /** | ||
| * query-actions reads the access token through security/methods.getAccessToken, | ||
| * so a registered resolver must be what every query* function sends. | ||
| * | ||
| * lodash/debounce is mocked to a passthrough so the query* functions run | ||
| * synchronously; the debounce delay is unrelated to the token path. | ||
| */ | ||
| import { setAccessTokenResolver } from "../../components/security/methods"; | ||
| import { queryMembers, querySummits } from "../query-actions"; | ||
|
|
||
| jest.mock("lodash/debounce", () => (fn) => fn); | ||
|
|
||
| const flush = async () => { | ||
| for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); | ||
| }; | ||
|
|
||
| describe("query-actions with an access-token resolver", () => { | ||
| let origFetch; | ||
|
|
||
| beforeEach(() => { | ||
| window.API_BASE_URL = "https://api.test"; | ||
| origFetch = global.fetch; | ||
| global.fetch = jest.fn(() => | ||
| Promise.resolve({ ok: true, json: () => Promise.resolve({ data: [{ id: 1 }] }) }) | ||
| ); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| setAccessTokenResolver(null); | ||
| global.fetch = origFetch; | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| test("sends the resolver's token as access_token", async () => { | ||
| const resolver = jest.fn(() => Promise.resolve("RES-TOK")); | ||
| setAccessTokenResolver(resolver); | ||
| const cb = jest.fn(); | ||
|
|
||
| queryMembers("x", cb); | ||
| await flush(); | ||
|
|
||
| expect(resolver).toHaveBeenCalled(); | ||
| expect(global.fetch).toHaveBeenCalledTimes(1); | ||
| const url = decodeURIComponent(global.fetch.mock.calls[0][0]); | ||
| expect(url).toContain("https://api.test/api/v1/members"); | ||
| expect(url).toContain("access_token=RES-TOK"); | ||
| expect(cb).toHaveBeenCalledWith([{ id: 1 }]); | ||
| }); | ||
|
|
||
| test("a resolver failure calls back with the error and does not fetch", async () => { | ||
| setAccessTokenResolver(() => Promise.reject(new Error("no session"))); | ||
| const cb = jest.fn(); | ||
|
|
||
| querySummits("x", cb); | ||
| await flush(); | ||
|
|
||
| expect(cb).toHaveBeenCalledWith(expect.any(Error)); | ||
| expect(global.fetch).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,8 +31,8 @@ const _fetchPublic = async (endpoint, callback, options = {}) => { | |
| callback(json.data); | ||
| }) | ||
| .catch(response => { | ||
| const code = response.status; | ||
| if (code === 404) callback([]); | ||
| const code = response && response.status; | ||
| if (code === 404 && typeof callback === 'function') callback([]); | ||
| return response; | ||
| }) | ||
| .catch(fetchErrorHandler); | ||
|
|
@@ -68,9 +68,12 @@ const _fetch = async (endpoint, callback, options = {}) => { | |
| try { | ||
| accessToken = await getAccessToken(); | ||
| } catch (e) { | ||
| // The caller is told through its callback; the query* functions do not | ||
| // await this promise, so rejecting here would only surface as an | ||
| // unhandled rejection. | ||
| if(typeof callback === 'function') | ||
| callback(e); | ||
| return Promise.reject(); | ||
| return; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
|
|
||
| endpoint.addQuery('access_token', accessToken); | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@gcutrini The resolver is shared only across bundles that resolve to the same installed copy of the package, so the singleton guarantee (and the "every uicore lib entry shares one methods instance" statement in the description) holds only when the host's dependency tree hoists a single openstack-uicore-foundation.
Concrete case: in the current event-site tree, my-orders-tickets-widget, full-schedule-widget and schedule-filter-widget each carry a nested node_modules/openstack-uicore-foundation (they pin uicore as an exact-version dependency), and my-orders-tickets-widget/dist/index.js does require("openstack-uicore-foundation/lib/security/methods"), which resolves to its nested copy. In a cookie-only host, the host registers the resolver on the top-level copy while the widget's calls hit a copy whose resolver is still null, fall into the localStorage flow and throw AUTH_ERROR_MISSING_AUTH_INFO.
Suggested: no code change here. State the precondition in this JSDoc and in the PR description: the host must end up with a single hoisted copy of the package, so widgets should declare uicore as a peerDependency only, not as a pinned dependency. That way the host knows to dedupe before relying on the resolver.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed at the root instead of documenting it: since bd79348 the resolver no longer lives in module state. It lives on globalThis under Symbol.for('openstack-uicore-foundation.accessTokenResolver'), so every copy of security/methods reads the same slot, including a nested copy inside a widget's own node_modules. The single-hoisted-copy precondition is gone. The only case that still misses is a nested copy of an OLDER uicore version without this code, and that is equally true of any approach.