Skip to content

Commit cbe9d2f

Browse files
Merge pull request #3075 from mainmatter/redirectTarget-to-use-sessionStorage
feat(ember-simple-auth): redirectTarget should be stored in sessionStorage when available
2 parents 89c2310 + 3783549 commit cbe9d2f

10 files changed

Lines changed: 216 additions & 8 deletions

File tree

packages/ember-simple-auth/src/-internals/routing.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ export function requireAuthentication(owner, transition, extraArgs) {
66
let sessionService = owner.lookup('service:session');
77
let isAuthenticated = sessionService.get('isAuthenticated');
88
if (!isAuthenticated) {
9-
const internalSession = sessionService.session;
109
let redirectTarget = extraArgs?.redirectTarget;
1110

1211
if (transition) {
@@ -18,7 +17,7 @@ export function requireAuthentication(owner, transition, extraArgs) {
1817
}
1918

2019
if (redirectTarget) {
21-
internalSession.setRedirectTarget(redirectTarget);
20+
sessionService.setRedirectTarget(redirectTarget);
2221
}
2322
}
2423
return isAuthenticated;
@@ -37,8 +36,7 @@ export function prohibitAuthentication(owner, routeIfAlreadyAuthenticated) {
3736
export function handleSessionAuthenticated(owner, routeAfterAuthentication) {
3837
let sessionService = owner.lookup('service:session');
3938
let attemptedTransition = sessionService.get('attemptedTransition');
40-
const internalSession = sessionService.session;
41-
const redirectTarget = internalSession.getRedirectTarget();
39+
const redirectTarget = sessionService.getRedirectTarget();
4240

4341
let routerService = owner.lookup('service:router');
4442

@@ -47,7 +45,7 @@ export function handleSessionAuthenticated(owner, routeAfterAuthentication) {
4745
sessionService.set('attemptedTransition', null);
4846
} else if (redirectTarget) {
4947
routerService.transitionTo(redirectTarget);
50-
internalSession.clearRedirectTarget();
48+
sessionService.clearRedirectTarget();
5149
} else {
5250
routerService.transitionTo(routeAfterAuthentication);
5351
}

packages/ember-simple-auth/src/internal-session.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,14 +288,14 @@ export default ObjectProxy.extend({
288288
},
289289

290290
setRedirectTarget(url) {
291-
this.store.setRedirectTarget(url);
291+
this.store.setRedirectTarget?.(url);
292292
},
293293

294294
getRedirectTarget() {
295-
return this.store.getRedirectTarget();
295+
return this.store.getRedirectTarget?.();
296296
},
297297

298298
clearRedirectTarget() {
299-
return this.store.clearRedirectTarget();
299+
return this.store.clearRedirectTarget?.();
300300
},
301301
});

packages/ember-simple-auth/src/services/session.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ export default class SessionService<Data = DefaultDataShape> extends Service {
135135
{@linkplain SessionService.requireAuthentication}
136136
If an attempted transition is present it will be retried.
137137
138+
This is an `in-memory` property, see {@linkplain SessionService.setRedirectTarget}, {@linkplain SessionService.getRedirectTarget} for a persistent redirect mechanism.
139+
`attemptedTransition` is used _first_ if set.
140+
138141
@memberof SessionService
139142
@property attemptedTransition
140143
@type Transition
@@ -144,6 +147,15 @@ export default class SessionService<Data = DefaultDataShape> extends Service {
144147
@alias('session.attemptedTransition')
145148
attemptedTransition: null | Transition = null;
146149

150+
get redirectTargetKey(): string | null {
151+
const store = this.store as { key?: string; cookieName?: string };
152+
const key = store.key || store.cookieName;
153+
if (key) {
154+
return `${key}-redirectTarget`;
155+
}
156+
return null;
157+
}
158+
147159
set(key: any, value: any) {
148160
const setsSessionData = SESSION_DATA_KEY_PREFIX.test(key);
149161
if (setsSessionData) {
@@ -238,6 +250,13 @@ export default class SessionService<Data = DefaultDataShape> extends Service {
238250
will be saved in a `ember_simple_auth-redirectTarget` cookie for use by the
239251
browser after authentication is complete.
240252
253+
Accepts an optional object with `redirectTarget` property. Related to {@linkplain SessionService.setRedirectTarget}, {@linkplain SessionService.getRedirectTarget}
254+
255+
@example
256+
// your-route.js
257+
this.session.requireAuthentication(transition, 'login', { redirectTarget: '/alternative-to-transition.intent.url' })
258+
259+
241260
@memberof SessionService
242261
@method requireAuthentication
243262
@param {Transition} transition A transition that triggered the authentication requirement or null if the requirement originated independently of a transition
@@ -354,4 +373,57 @@ export default class SessionService<Data = DefaultDataShape> extends Service {
354373
// If it raises an error then it means that restore didn't find any restorable state.
355374
});
356375
}
376+
377+
/**
378+
Stores the `redirectTarget` in both `globalThis.sessionStorage` and the configured `session-store`.
379+
Key is computed based on the `session-store:application` `key` or `cookieName` property.
380+
381+
This method is internally called by {@linkplain SessionService.requireAuthentication}.
382+
383+
@memberof SessionService
384+
@method setRedirectTarget
385+
@public
386+
*/
387+
setRedirectTarget(url: string) {
388+
this.session.setRedirectTarget(url);
389+
if (this.redirectTargetKey) {
390+
globalThis.sessionStorage?.setItem(this.redirectTargetKey, url);
391+
}
392+
}
393+
394+
/**
395+
Retrieves the `redirectTarget` from `globalThis.sessionStorage` first,
396+
falls back to `session-store:application` when nothing's found.
397+
398+
This method is internally called by {@linkplain SessionService.handleAuthentication}.
399+
400+
@memberof SessionService
401+
@method getRedirectTarget
402+
@public
403+
*/
404+
getRedirectTarget() {
405+
let redirectTarget: string | null = this.session.getRedirectTarget();
406+
407+
if (this.redirectTargetKey) {
408+
return globalThis.sessionStorage?.getItem(this.redirectTargetKey) || redirectTarget;
409+
} else {
410+
return redirectTarget;
411+
}
412+
}
413+
414+
/**
415+
Clears the `redirectTarget` from `globalThis.sessionStorage` and the `session-store:application`.
416+
417+
This method is internally called by {@linkplain SessionService.handleAuthentication}.
418+
419+
@memberof SessionService
420+
@method clearRedirectTarget
421+
@public
422+
*/
423+
clearRedirectTarget() {
424+
this.session.clearRedirectTarget();
425+
if (this.redirectTargetKey) {
426+
globalThis.sessionStorage?.removeItem(this.redirectTargetKey);
427+
}
428+
}
357429
}

packages/ember-simple-auth/src/session-stores/base.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,52 @@ export default abstract class EsaBaseSessionStore extends EmberObject {
7373
*/
7474
abstract clear(): Promise<unknown>;
7575

76+
/**
77+
Called by {@linkplain SessionService.setRedirectTarget}.
78+
79+
This method is used to persist a `redirectTarget` which is an alternative to the non-persistent {@linkplain SessionService.attemptedTransition}.
80+
`redirectTarget` is meant to replace {@linkplain SessionService.attemptedTransition} that is discarded the moment a browser tab is refreshed.
81+
82+
This method is meant to be always implemented but can be `undefined` for backwards compatibility.
83+
Additionally can be assigned `null` to opt out of using this mechanism.
84+
85+
@example
86+
setRedirectTarget = null;
87+
88+
@memberof BaseStore
89+
@method setRedirectTarget
90+
@public
91+
*/
7692
abstract setRedirectTarget(urL: string): void;
93+
94+
/**
95+
Called by {@linkplain SessionService.getRedirectTarget}.
96+
97+
This method is retrieve a persisted `redirectTarget` from a child class' store mechanism.
98+
Additionally can be assigned `null` to opt out of using this mechanism.
99+
100+
@example
101+
getRedirectTarget = null;
102+
103+
@memberof BaseStore
104+
@method getRedirectTarget
105+
@public
106+
*/
77107
abstract getRedirectTarget(): string | null;
108+
109+
/**
110+
Called by {@linkplain SessionService.clearRedirectTarget}.
111+
112+
This method clears a `redirectTarget` from a child class' store mechanism.
113+
Additionally can be assigned `null` to opt out of using this mechanism.
114+
115+
@example
116+
clearRedirectTarget = null;
117+
118+
@memberof BaseStore
119+
@method clearRedirectTarget
120+
@public
121+
*/
78122
abstract clearRedirectTarget(): void;
79123

80124
on<Event extends keyof SessionEvents>(event: Event, cb: EventListener<SessionEvents, Event>) {

packages/playwright-tests/tests/test-app.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,5 +111,34 @@ STORAGE_SCENARIOS.forEach(scenario => {
111111
await loginWithPassword(page);
112112
await confirmLoggedIn(page, { expectedUrl: '/protected' });
113113
});
114+
115+
test('user is redirected to the last visited route for a given browser tab session', async ({
116+
page,
117+
context,
118+
}) => {
119+
test.skip(
120+
process.env.FASTBOOT_DISABLED !== 'true',
121+
'This feature relies on SessionStorage which is unavailable in fastboot.'
122+
);
123+
await specifyTestAppStorageAdapter(page, scenario);
124+
await page.goto('/protected');
125+
126+
await page.getByTestId('route-login').click();
127+
await expect(page).toHaveURL('/login#');
128+
129+
const anotherPage = await context.newPage();
130+
await specifyTestAppStorageAdapter(anotherPage, scenario);
131+
await anotherPage.goto('/another-protected');
132+
133+
// Make sure to verify persistence
134+
await anotherPage.reload();
135+
await page.reload();
136+
137+
await expect(anotherPage).toHaveURL('/login');
138+
139+
await loginWithPassword(page);
140+
await confirmLoggedIn(page, { expectedUrl: '/protected' });
141+
await confirmLoggedIn(anotherPage, { expectedUrl: '/another-protected' });
142+
});
114143
});
115144
});

packages/test-app/app/router.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class Router extends EmberRouter {
99
Router.map(function () {
1010
this.route('login');
1111
this.route('protected');
12+
this.route('another-protected');
1213
this.route('auth-error');
1314
this.route('callback');
1415
this.mount('my-engine', { as: 'engine', path: '/engine' });
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Route from '@ember/routing/route';
2+
import { service } from '@ember/service';
3+
4+
export default class AnotherProtectedRoute extends Route {
5+
@service session;
6+
@service store;
7+
8+
beforeModel(transition) {
9+
this.session.requireAuthentication(transition, 'login');
10+
}
11+
12+
model() {
13+
return this.store.findAll('post');
14+
}
15+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<h1>Another Protected Page</h1>
2+
<div class="alert alert-warning">
3+
This is an "another" protected page only visible to authenticated users!
4+
</div>
5+
<h2>Posts</h2>
6+
<ul>
7+
{{#each this.model as |post|}}
8+
<li>
9+
<h3>{{post.title}}</h3>
10+
<p>{{post.body}}</p>
11+
</li>
12+
{{/each}}
13+
</ul>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export class MockSessionStorage {
2+
constructor() {
3+
this.store = new Map();
4+
}
5+
6+
get length() {
7+
return this.store.size;
8+
}
9+
10+
clear() {
11+
this.store.clear();
12+
}
13+
14+
getItem(key) {
15+
return this.store.get(key) ?? null;
16+
}
17+
18+
removeItem(key) {
19+
this.store.delete(key);
20+
}
21+
22+
setItem(key, value) {
23+
this.store.set(key, String(value));
24+
}
25+
}

packages/test-esa/tests/unit/services/session-test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { setupTest } from 'ember-qunit';
33
import Service from '@ember/service';
44
import EmberObject, { set } from '@ember/object';
55
import sinonjs from 'sinon';
6+
import { MockSessionStorage } from '../../helpers/mocked-session-storage';
67

78
module('SessionService', function (hooks) {
89
setupTest(hooks);
@@ -13,6 +14,7 @@ module('SessionService', function (hooks) {
1314

1415
hooks.beforeEach(function () {
1516
sinon = sinonjs.createSandbox();
17+
sinon.stub(window, 'sessionStorage').value(new MockSessionStorage());
1618
this.owner.register(
1719
'authorizer:custom',
1820
EmberObject.extend({
@@ -281,6 +283,15 @@ module('SessionService', function (hooks) {
281283
session.getRedirectTarget();
282284
assert.ok(readCookieStub.calledWith(cookieName));
283285
});
286+
287+
test("doesn't throw when session-store doesn't implement redirectTarget methods", function (assert) {
288+
assert.expect(0);
289+
session.store.setRedirectTarget = null;
290+
session.store.getRedirectTarget = null;
291+
session.store.clearRedirectTarget = null;
292+
293+
sessionService.requireAuthentication(transition, 'login', { redirectTarget });
294+
});
284295
});
285296

286297
module('if no transition is passed', function () {

0 commit comments

Comments
 (0)