|
@@ -0,0 +1,442 @@
|
|
1
|
+# Spring Boot, Ionic, and Stormpath
|
|
2
|
+
|
|
3
|
+This tutorial shows how to build a secure Spring Boot API with Stormpath. It also shows how to build an Ionic app that securely connects to this API and can be deployed to a mobile device.
|
|
4
|
+
|
|
5
|
+## Spring Boot API
|
|
6
|
+
|
|
7
|
+Create your Spring Boot API project using [start.spring.io](https://start.spring.io).
|
|
8
|
+
|
|
9
|
+```
|
|
10
|
+http https://start.spring.io/starter.zip \
|
|
11
|
+dependencies==data-jpa,data-rest,h2,web,devtools,security,stormpath -d
|
|
12
|
+```
|
|
13
|
+
|
|
14
|
+Run the application with `./mvnw spring-boot:run`.
|
|
15
|
+
|
|
16
|
+Create a `Beer` entity class in `src/main/java/com/example/beer`.
|
|
17
|
+
|
|
18
|
+```java
|
|
19
|
+package com.example.beer;
|
|
20
|
+
|
|
21
|
+import javax.persistence.Entity;
|
|
22
|
+import javax.persistence.GeneratedValue;
|
|
23
|
+import javax.persistence.Id;
|
|
24
|
+
|
|
25
|
+@Entity
|
|
26
|
+public class Beer {
|
|
27
|
+
|
|
28
|
+ @Id
|
|
29
|
+ @GeneratedValue
|
|
30
|
+ private Long id;
|
|
31
|
+ private String name;
|
|
32
|
+
|
|
33
|
+ public Beer() {
|
|
34
|
+ }
|
|
35
|
+
|
|
36
|
+ public Beer(String name) {
|
|
37
|
+ this.name = name;
|
|
38
|
+ }
|
|
39
|
+
|
|
40
|
+ public Long getId() {
|
|
41
|
+ return id;
|
|
42
|
+ }
|
|
43
|
+
|
|
44
|
+ public void setId(Long id) {
|
|
45
|
+ this.id = id;
|
|
46
|
+ }
|
|
47
|
+
|
|
48
|
+ public String getName() {
|
|
49
|
+ return name;
|
|
50
|
+ }
|
|
51
|
+
|
|
52
|
+ public void setName(String name) {
|
|
53
|
+ this.name = name;
|
|
54
|
+ }
|
|
55
|
+
|
|
56
|
+ @Override
|
|
57
|
+ public String toString() {
|
|
58
|
+ return "Beer{" +
|
|
59
|
+ "id=" + id +
|
|
60
|
+ ", name='" + name + '\'' +
|
|
61
|
+ '}';
|
|
62
|
+ }
|
|
63
|
+}
|
|
64
|
+```
|
|
65
|
+
|
|
66
|
+Create a JPA Repository to manage the `Beer` entity.
|
|
67
|
+
|
|
68
|
+```java
|
|
69
|
+package com.example.beer;
|
|
70
|
+
|
|
71
|
+import org.springframework.data.jpa.repository.JpaRepository;
|
|
72
|
+import org.springframework.data.rest.core.annotation.RepositoryRestResource;
|
|
73
|
+
|
|
74
|
+@RepositoryRestResource
|
|
75
|
+interface BeerRepository extends JpaRepository<Beer, Long> {
|
|
76
|
+}
|
|
77
|
+```
|
|
78
|
+
|
|
79
|
+Create a CommandLineRunner to populate the database.
|
|
80
|
+
|
|
81
|
+```java
|
|
82
|
+package com.example.beer;
|
|
83
|
+
|
|
84
|
+import org.springframework.boot.CommandLineRunner;
|
|
85
|
+import org.springframework.stereotype.Component;
|
|
86
|
+
|
|
87
|
+import java.util.stream.Stream;
|
|
88
|
+
|
|
89
|
+@Component
|
|
90
|
+class BeerCommandLineRunner implements CommandLineRunner {
|
|
91
|
+
|
|
92
|
+ public BeerCommandLineRunner(BeerRepository repository) {
|
|
93
|
+ this.repository = repository;
|
|
94
|
+ }
|
|
95
|
+
|
|
96
|
+ @Override
|
|
97
|
+ public void run(String... strings) throws Exception {
|
|
98
|
+ // top 5 beers from https://www.beeradvocate.com/lists/top/
|
|
99
|
+ Stream.of("Good Morning", "Kentucky Brunch Brand Stout", "ManBearPig", "King Julius",
|
|
100
|
+ "Very Hazy", "Budweiser", "Coors Light", "PBR").forEach(name ->
|
|
101
|
+ repository.save(new Beer(name))
|
|
102
|
+ );
|
|
103
|
+ System.out.println(repository.findAll());
|
|
104
|
+ }
|
|
105
|
+
|
|
106
|
+ private final BeerRepository repository;
|
|
107
|
+}
|
|
108
|
+```
|
|
109
|
+
|
|
110
|
+Create a `BeerController` for your REST API. Add some business logic that results in a `/good-beers` endpoint.
|
|
111
|
+
|
|
112
|
+```java
|
|
113
|
+package com.example.beer;
|
|
114
|
+
|
|
115
|
+import org.springframework.web.bind.annotation.GetMapping;
|
|
116
|
+import org.springframework.web.bind.annotation.RestController;
|
|
117
|
+
|
|
118
|
+import java.util.Collection;
|
|
119
|
+import java.util.HashMap;
|
|
120
|
+import java.util.Map;
|
|
121
|
+import java.util.stream.Collectors;
|
|
122
|
+
|
|
123
|
+@RestController
|
|
124
|
+public class BeerController {
|
|
125
|
+ private BeerRepository repository;
|
|
126
|
+
|
|
127
|
+ public BeerController(BeerRepository repository) {
|
|
128
|
+ this.repository = repository;
|
|
129
|
+ }
|
|
130
|
+
|
|
131
|
+ @GetMapping("/good-beers")
|
|
132
|
+ public Collection<Map<String, String>> goodBeers() {
|
|
133
|
+
|
|
134
|
+ return repository.findAll().stream()
|
|
135
|
+ .filter(this::isGreat)
|
|
136
|
+ .map(b -> {
|
|
137
|
+ Map<String, String> m = new HashMap<>();
|
|
138
|
+ m.put("id", b.getId().toString());
|
|
139
|
+ m.put("name", b.getName());
|
|
140
|
+ return m;
|
|
141
|
+ }).collect(Collectors.toList());
|
|
142
|
+ }
|
|
143
|
+
|
|
144
|
+ private boolean isGreat(Beer beer) {
|
|
145
|
+ return !beer.getName().equals("Budweiser") &&
|
|
146
|
+ !beer.getName().equals("Coors Light") &&
|
|
147
|
+ !beer.getName().equals("PBR");
|
|
148
|
+ }
|
|
149
|
+}
|
|
150
|
+
|
|
151
|
+```
|
|
152
|
+
|
|
153
|
+Access the API using `http localhost:8080/good-beers --auth <user>:<password>`.
|
|
154
|
+
|
|
155
|
+## Ionic App
|
|
156
|
+
|
|
157
|
+Install Ionic and Cordova using npm: `yarn global add cordova ionic`
|
|
158
|
+
|
|
159
|
+From a terminal window, create a new application using the following command:
|
|
160
|
+
|
|
161
|
+```
|
|
162
|
+ionic start ionic-auth --v2
|
|
163
|
+```
|
|
164
|
+
|
|
165
|
+This may take a minute or two to complete, depending on your internet connection speed. In the same terminal window, change to be in your application’s directory and run it.
|
|
166
|
+
|
|
167
|
+```
|
|
168
|
+cd ionic-auth
|
|
169
|
+ionic serve
|
|
170
|
+```
|
|
171
|
+
|
|
172
|
+This will open your default browser on http://localhost:8100. You can use Chrome’s device toolbar to see what the application will look like on most mobile devices.
|
|
173
|
+
|
|
174
|
+Stormpath allows you to easily integrate authentication into an Ionic 2 application. It also provides [integration for a number of backend frameworks](https://docs.stormpath.com/), making it easy to verify the JWT you get when logging in.
|
|
175
|
+
|
|
176
|
+Thanks to the [recent release of our Client API](https://stormpath.com/blog/client-api-authentication-mobile-frontend), you can now authenticate directly against our API without needing to hit your server with our integration installed. This article shows you how to do just that in an Ionic application.
|
|
177
|
+
|
|
178
|
+Install the [Angular components for Stormpath](https://github.com/stormpath/stormpath-sdk-angular) using npm.
|
|
179
|
+
|
|
180
|
+```
|
|
181
|
+yarn add angular-stormpath
|
|
182
|
+```
|
|
183
|
+
|
|
184
|
+Modify `app.module.ts` to import `StormpathConfiguration` and `StormpathModule`. Then create a function to configure the `endpointPrefix` to point to the DNS label for your [Client API](https://docs.stormpath.com/client-api/product-guide/latest/) instance. You can find and configure your DNS label by logging into https://api.stormpath.com and navigating to Applications > My Application > Policies > Client API > DNS Label. Since mine is “raible”, I’ll be using `raible.apps.stormpath.io` for this example.
|
|
185
|
+
|
|
186
|
+```typescript
|
|
187
|
+import { StormpathModule, StormpathConfiguration } from 'angular-stormpath';
|
|
188
|
+...
|
|
189
|
+export function stormpathConfig(): StormpathConfiguration {
|
|
190
|
+ let spConfig: StormpathConfiguration = new StormpathConfiguration();
|
|
191
|
+ spConfig.endpointPrefix = 'https://raible.apps.stormpath.io';
|
|
192
|
+ return spConfig;
|
|
193
|
+}
|
|
194
|
+
|
|
195
|
+@NgModule({
|
|
196
|
+ ...
|
|
197
|
+ imports: [
|
|
198
|
+ IonicModule.forRoot(MyApp),
|
|
199
|
+ StormpathModule,
|
|
200
|
+ StormpathIonicModule
|
|
201
|
+ ],
|
|
202
|
+ bootstrap: [IonicApp],
|
|
203
|
+ entryComponents: [
|
|
204
|
+ ...
|
|
205
|
+ LoginPage,
|
|
206
|
+ ForgotPasswordPage,
|
|
207
|
+ RegisterPage
|
|
208
|
+ ],
|
|
209
|
+ providers: [
|
|
210
|
+ {provide: ErrorHandler, useClass: IonicErrorHandler},
|
|
211
|
+ {provide: StormpathConfiguration, useFactory: stormpathConfig}
|
|
212
|
+ ]
|
|
213
|
+})
|
|
214
|
+export class AppModule {}
|
|
215
|
+```
|
|
216
|
+
|
|
217
|
+To render a login page before users can view the application, you can modify `src/app/app.component.ts` to use the `Stormpath` service and navigate to Stormpath's `LoginPage` if the user is not authenticated.
|
|
218
|
+
|
|
219
|
+```typescript
|
|
220
|
+import { Component } from '@angular/core';
|
|
221
|
+import { Platform } from 'ionic-angular';
|
|
222
|
+import { StatusBar, Splashscreen } from 'ionic-native';
|
|
223
|
+import { TabsPage } from '../pages/tabs/tabs';
|
|
224
|
+import { Stormpath, LoginPage } from 'angular-stormpath';
|
|
225
|
+
|
|
226
|
+@Component({
|
|
227
|
+ templateUrl: 'app.html'
|
|
228
|
+})
|
|
229
|
+export class MyApp {
|
|
230
|
+ rootPage;
|
|
231
|
+
|
|
232
|
+ constructor(platform: Platform, private stormpath: Stormpath) {
|
|
233
|
+ stormpath.user$.subscribe(user => {
|
|
234
|
+ if (!user) {
|
|
235
|
+ this.rootPage = LoginPage;
|
|
236
|
+ } else {
|
|
237
|
+ this.rootPage = TabsPage;
|
|
238
|
+ }
|
|
239
|
+ });
|
|
240
|
+
|
|
241
|
+ platform.ready().then(() => {
|
|
242
|
+ // Okay, so the platform is ready and our plugins are available.
|
|
243
|
+ // Here you can do any higher level native things you might need.
|
|
244
|
+ StatusBar.styleDefault();
|
|
245
|
+ Splashscreen.hide();
|
|
246
|
+ });
|
|
247
|
+ }
|
|
248
|
+}
|
|
249
|
+```
|
|
250
|
+
|
|
251
|
+After making these changes, you can run `ionic serve`, but you’ll likely see something similar to the following error in your browser’s console.
|
|
252
|
+
|
|
253
|
+<pre style=”color: red”>
|
|
254
|
+XMLHttpRequest cannot load https://raible.apps.stormpath.io/me. Response to preflight request
|
|
255
|
+ doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on
|
|
256
|
+ the requested resource. Origin 'http://localhost:8100 is therefore not allowed access.
|
|
257
|
+ The response had HTTP status code 403.
|
|
258
|
+</pre>
|
|
259
|
+
|
|
260
|
+To fix this, you’ll need to login to https://api.stormpath.com, and navigate to Applications > My Application, and modify the **Authorized Origin URIs** to include `http://localhost:8100`.
|
|
261
|
+
|
|
262
|
+At this point, you should see a login screen when you run `ionic serve`.
|
|
263
|
+
|
|
264
|
+![Stormpath Login for Ionic](./static/ionic-login.png)
|
|
265
|
+
|
|
266
|
+If you don’t see this screen, it’s possible you’re still logged in. Clearing your local storage will fix this, or you can continue below to add the ability to logout.
|
|
267
|
+
|
|
268
|
+In `src/pages/home.html`, add a logout link to the header and a paragraph in the content section that shows the currently logged in user.
|
|
269
|
+
|
|
270
|
+```html
|
|
271
|
+<ion-header>
|
|
272
|
+ <ion-navbar>
|
|
273
|
+ <ion-title>Home</ion-title>
|
|
274
|
+ <ion-buttons end>
|
|
275
|
+ <button ion-button icon-only (click)="logout()">
|
|
276
|
+ Logout
|
|
277
|
+ </button>
|
|
278
|
+ </ion-buttons>
|
|
279
|
+ </ion-navbar>
|
|
280
|
+</ion-header>
|
|
281
|
+
|
|
282
|
+<ion-content padding>
|
|
283
|
+ <h2>Welcome to Ionic!</h2>
|
|
284
|
+ <p>
|
|
285
|
+ This starter project comes with simple tabs-based layout for apps
|
|
286
|
+ that are going to primarily use a Tabbed UI.
|
|
287
|
+ </p>
|
|
288
|
+ <p>
|
|
289
|
+ Take a look at the <code>src/pages/</code> directory to add or change tabs,
|
|
290
|
+ update any existing page or create new pages.
|
|
291
|
+ </p>
|
|
292
|
+ <p *ngIf="(user$ | async)">
|
|
293
|
+ You are logged in as: <b>{{ ( user$ | async ).fullName }}</b>
|
|
294
|
+ </p>
|
|
295
|
+</ion-content>
|
|
296
|
+```
|
|
297
|
+
|
|
298
|
+If you login, the “Logout” button will render, but won’t work because there’s no `logout()` method in `src/pages/home.ts`. Similarly, the “You are logged in” message won’t appear because there’s no `user$` variable defined. Change the body of `home.ts` to retrieve `user$` from the `Stormpath` service and define the `logout()` method.
|
|
299
|
+
|
|
300
|
+```typescript
|
|
301
|
+import { Account, Stormpath } from 'angular-stormpath';
|
|
302
|
+import { Observable } from 'rxjs';
|
|
303
|
+...
|
|
304
|
+export class HomePage {
|
|
305
|
+ user$: Observable<Account | boolean>;
|
|
306
|
+
|
|
307
|
+ constructor(private stormpath: Stormpath) {
|
|
308
|
+ this.user$ = this.stormpath.user$;
|
|
309
|
+ }
|
|
310
|
+
|
|
311
|
+ logout(): void {
|
|
312
|
+ this.stormpath.logout();
|
|
313
|
+ }
|
|
314
|
+}
|
|
315
|
+```
|
|
316
|
+
|
|
317
|
+If you’re logged in, you should see a screen with a logout button and the name of the currently logged in user.
|
|
318
|
+
|
|
319
|
+![Logged in as: Hip User](./static/ionic-home.png)
|
|
320
|
+
|
|
321
|
+The `LoginPage` tries to auto-focus onto the `email` field when it loads. To auto-activate the keyboard you'll need to tell Cordova it’s OK to display the keyboard without user interaction. You can do this by adding the following to `config.xml` in the root directory.
|
|
322
|
+
|
|
323
|
+```xml
|
|
324
|
+<preference name="KeyboardDisplayRequiresUserAction" value="false" />
|
|
325
|
+```
|
|
326
|
+
|
|
327
|
+## PWAs with Ionic
|
|
328
|
+
|
|
329
|
+Ionic 2 ships with support for creating progressive web apps (PWAs). If you’d like to learn more about what PWAs are, see [Navigating the World of Progressive Web Apps with Ionic 2](http://blog.ionic.io/navigating-the-world-of-progressive-web-apps-with-ionic-2/).
|
|
330
|
+
|
|
331
|
+If you run the [Lighthouse Chrome extension](https://developers.google.com/web/tools/lighthouse/) on this application, you’ll get a mediocre score (54/100).
|
|
332
|
+
|
|
333
|
+To register a service worker, and improve the app’s score, uncomment the following block in `index.html`.
|
|
334
|
+
|
|
335
|
+```html
|
|
336
|
+<!-- un-comment this code to enable service worker
|
|
337
|
+<script>
|
|
338
|
+ if ('serviceWorker' in navigator) {
|
|
339
|
+ navigator.serviceWorker.register('service-worker.js')
|
|
340
|
+ .then(() => console.log('service worker installed'))
|
|
341
|
+ .catch(err => console.log('Error', err));
|
|
342
|
+ }
|
|
343
|
+</script>-->
|
|
344
|
+```
|
|
345
|
+
|
|
346
|
+After making this change, the score should improve. In my tests, it increased to 69/100. The remaining issues were:
|
|
347
|
+
|
|
348
|
+* The page body should render some content if its scripts are not available. This could likely be solved with [Angular’s app-shell directives](https://www.npmjs.com/package/@angular/app-shell).
|
|
349
|
+* Site is not on HTTPS and does not redirect HTTP to HTTPS.
|
|
350
|
+* A couple -1’s in performance for "Cannot read property 'ts' of undefined”.
|
|
351
|
+
|
|
352
|
+If you refresh the app and Chrome doesn’t prompt you to install the app (a PWA feature), you probably need to turn on a couple of features. Copy and paste the following URLs into Chrome and enable each feature.
|
|
353
|
+
|
|
354
|
+```
|
|
355
|
+chrome://flags/#bypass-app-banner-engagement-checks
|
|
356
|
+chrome://flags/#enable-add-to-shelf
|
|
357
|
+```
|
|
358
|
+
|
|
359
|
+After enabling these flags, you’ll see an error in your browser’s console about `assets/imgs/logo.png` not being found. This files is referenced in `src/manifest.json`. You can fix this by copying a 512x512 PNG into this location or by modifying `manifest.json` accordingly.
|
|
360
|
+
|
|
361
|
+## Deploy to a Mobile Device
|
|
362
|
+
|
|
363
|
+It’s pretty cool that you’re able to develop mobile apps with Ionic in your browser. However, it’s nice to see the fruits of your labor and see how awesome your app looks on a phone. It really does look and behave like a native app!
|
|
364
|
+
|
|
365
|
+To see how your application will look on different devices you can run `ionic serve --lab`. The `--lab` flag opens opens a page in your browser that lets you see how your app looks on different devices.
|
|
366
|
+
|
|
367
|
+### iOS
|
|
368
|
+
|
|
369
|
+To emulate or deploy to an iOS device, you’ll need a Mac and a fresh installation of [Xcode](https://developer.apple.com/xcode/). If you’d like to build iOS apps on Windows, Ionic offers an [Ionic Package](http://ionic.io/cloud#packaging) service.
|
|
370
|
+
|
|
371
|
+Make sure to open Xcode to complete the installation.
|
|
372
|
+
|
|
373
|
+To make your app iOS-capable, add support for it using the following command.
|
|
374
|
+
|
|
375
|
+```
|
|
376
|
+ionic platform add ios
|
|
377
|
+```
|
|
378
|
+
|
|
379
|
+You’ll need to run `ionic emulate ios` to open your app in Simulator.
|
|
380
|
+
|
|
381
|
+The biggest problem I found when running the app in Simulator was that it was difficult to get the keyboard to popup. To workaround this, I used Edit > Hardware > Keyboard > Toggle Software Keyboard when I needed to type text in a field.
|
|
382
|
+
|
|
383
|
+To deploy the app to an iPhone, start by plugging your iOS device into your computer. Then run the following commands to install ios-deploy/ios-sim, build the app, and run it on your device.
|
|
384
|
+
|
|
385
|
+```
|
|
386
|
+npm install -g ios-deploy ios-sim
|
|
387
|
+ionic build ios
|
|
388
|
+cd platforms/ios/
|
|
389
|
+open ionic-auth.xcodeproj
|
|
390
|
+```
|
|
391
|
+
|
|
392
|
+Select your phone as the target in Xcode and click the play button to run your app. The first time you do this, Xcode may spin for a while with a “Processing symbol files” message at the top.
|
|
393
|
+
|
|
394
|
+See Ionic’s [deploying documentation](https://ionicframework.com/docs/v2/setup/deploying/) for information on code signing and trusting the app’s certificate.
|
|
395
|
+
|
|
396
|
+Once you’re configured your phone, computer, and Apple ID to work, you should be able to open the app and see screens like the ones I captured on my iPhone 6s Plus.
|
|
397
|
+
|
|
398
|
+|<img src="./static/iphone-login.png" width="200">|<img src="./static/iphone-register.png" width="200">|<img src="./static/iphone-forgot-password.png" width="200">)|
|
|
399
|
+
|
|
400
|
+### Android
|
|
401
|
+
|
|
402
|
+To emulate or deploy to an Android device, you’ll first need to install [Android Studio](https://developer.android.com/studio/index.html). As part of the install, it will show you where it installed the Android SDK. Set this path as an ANDROID_HOME environment variable. On a Mac, it should be `~/Library/Android/sdk/`.
|
|
403
|
+
|
|
404
|
+Make sure to open Android Studio to complete the installation.
|
|
405
|
+
|
|
406
|
+To deploy to the Android emulator, add support for it to the ionic-auth project using the `ionic` command.
|
|
407
|
+
|
|
408
|
+```
|
|
409
|
+ionic platform add android
|
|
410
|
+```
|
|
411
|
+
|
|
412
|
+If you run `ionic emulate android` you’ll get instructions from about how to create an emulator image.
|
|
413
|
+
|
|
414
|
+```
|
|
415
|
+Error: No emulator images (avds) found.
|
|
416
|
+1. Download desired System Image by running: /Users/mraible/Library/Android/sdk/tools/android sdk
|
|
417
|
+2. Create an AVD by running: /Users/mraible/Library/Android/sdk/tools/android avd
|
|
418
|
+HINT: For a faster emulator, use an Intel System Image and install the HAXM device driver
|
|
419
|
+```
|
|
420
|
+
|
|
421
|
+I ran the first suggestion and downloaded my desired system image. Then I ran the second command and created an AVD with the following settings:
|
|
422
|
+
|
|
423
|
+```
|
|
424
|
+AVD Name: TestPhone
|
|
425
|
+Device: Nexus 5
|
|
426
|
+Target: Android 7.1.1
|
|
427
|
+CPU/ABI: Google APIs Intel Axom (x86_64)
|
|
428
|
+Skin: Skin with dynamic hardware controls
|
|
429
|
+```
|
|
430
|
+
|
|
431
|
+After performing these steps, I was able to run `ionic emulate android` and see my app running in the AVD.
|
|
432
|
+
|
|
433
|
+## Learn More
|
|
434
|
+I hope you’ve enjoyed this tour of Ionic, Angular, and Stormpath. I like how Ionic takes your web development skills up a notch and allows you to create mobile applications that look and behave natively. They look great and have swift performance.
|
|
435
|
+
|
|
436
|
+I’d love to learn how to add [Touch ID](https://ionicframework.com/docs/v2/native/touchid/) or [1Password support](https://github.com/AgileBits/onepassword-app-extension) to an Ionic app with Stormpath authentication. One of the biggest pain points as a user is having to enter in usernames and passwords on a mobile application. While Stormpath’s use of OAuth 2’s access and refresh tokens helps alleviate the problem, I do like the convenience of leveraging my phone and app’s capabilities.
|
|
437
|
+
|
|
438
|
+To learn more about Ionic, Angular, or Stormpath, please see the following resources:
|
|
439
|
+
|
|
440
|
+[Get started with Ionic Framework](http://ionicframework.com/getting-started/)
|
|
441
|
+[Getting Started with Angular](https://www.youtube.com/watch?v=Jq3szz2KOOs) A YouTube webinar by yours truly. ;)
|
|
442
|
+[Stormpath Client API Guide](https://docs.stormpath.com/client-api/product-guide/latest/)
|