Skip to content

Commit

Permalink
option to login to node-red with sso
Browse files Browse the repository at this point in the history
  • Loading branch information
andrei-tatar committed Mar 6, 2022
1 parent 8d28404 commit d4d2eb4
Show file tree
Hide file tree
Showing 8 changed files with 182 additions and 85 deletions.
10 changes: 7 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
export interface NoraConfig {
group?: string;
email: string;
password: string;
password?: string;
sso?: string;
}

export const apiEndpoint = 'https://api.smart-nora.eu/client/';
export const API_ENDPOINT = 'https://api.smart-nora.eu';

export const firebaseConfig = {
const { name, version } = require('../package.json');
export const USER_AGENT = `${name}/${version}`;

export const FIREBASE_CONFIG = {
apiKey: 'AIzaSyCE4ogvmNJG8Vvkzf1wfWKhjzCALlLGLsw',
authDomain: 'nora-firebase.firebaseapp.com',
databaseURL: 'https://nora-firebase-default-rtdb.europe-west1.firebasedatabase.app',
Expand Down
4 changes: 2 additions & 2 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { deleteApp, initializeApp } from 'firebase/app';
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';
import { getDatabase, Database, ref, DatabaseReference, child, update, onValue, remove } from 'firebase/database';
import { firebaseConfig } from './config';
import { FIREBASE_CONFIG } from './config';

interface ContextConfiguration {
email: string;
Expand Down Expand Up @@ -42,7 +42,7 @@ class FirebaseContextStorage {
async open() {
await this.close();

const app = initializeApp(firebaseConfig, 'app-context');
const app = initializeApp(FIREBASE_CONFIG, 'app-context');
const auth = getAuth(app);
const { user } = await signInWithEmailAndPassword(auth, this.config.email, this.config.password);
this.db = getDatabase(app);
Expand Down
26 changes: 21 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { Device } from '@andrei-tatar/nora-firebase-common';
import {
concat, connectable, EMPTY, MonoTypeOperatorFunction,
Observable, of, ReplaySubject, Subscription, timer
} from 'rxjs';
import { concat, EMPTY, MonoTypeOperatorFunction, of, ReplaySubject, timer } from 'rxjs';
import { filter, map, scan, share, switchMap } from 'rxjs/operators';
import type { Response as NodeFetchResponse } from 'node-fetch';

export interface NodeMessage extends Record<string, any> {
payload: any;
Expand Down Expand Up @@ -44,7 +42,8 @@ export interface Logger {

export interface ConfigNode {
email: string;
password: string;
password?: string;
sso?: string;
group?: string;
valid: boolean;
localExecution: boolean;
Expand Down Expand Up @@ -126,3 +125,20 @@ export function singleton<T>(): MonoTypeOperatorFunction<T> {
})
);
}

export function shouldRetryRequest(response: NodeFetchResponse) {
if (response.status === 429) {
return true;
}

const status = Math.floor(response.status / 100);
return status !== 2 && status !== 4;
}

export class HttpError extends Error {
constructor(
public readonly statusCode: number,
public readonly content: string) {
super(`HTTP response (${statusCode} ${content})`);
}
}
127 changes: 81 additions & 46 deletions src/nodes/nora-config.html
Original file line number Diff line number Diff line change
@@ -1,51 +1,85 @@
<script type="text/javascript">
RED.nodes.registerType('noraf-config', {
category: 'config',
defaults: {
name: {
value: 'nora config'
},
group: {
value: '',
},
twofactor: {
value: 'off',
},
twofactorpin: {
value: ''
},
localexecution: {
value: true,
(function () {
const noraOrigin = 'https://smart-nora.eu';
let ssoWindow = null;
let handler = null;
window.addEventListener('message', ev => {
if (ev.origin.startsWith(noraOrigin) && handler) handler(ev.data);
});
RED.nodes.registerType('noraf-config', {
category: 'config',
defaults: {
name: {
value: 'nora config'
},
group: {
value: '',
},
twofactor: {
value: 'off',
},
twofactorpin: {
value: ''
},
localexecution: {
value: true,
},
structure: {
value: ''
},
storeStateInContext: {
value: false,
}
},
structure: {
value: ''
credentials: {
email: {
type: 'text'
},
password: {
type: 'text'
},
sso: {
type: 'text'
}
},
storeStateInContext: {
value: false,
}
},
credentials: {
email: {
type: 'text'
paletteLabel: 'nora config',
label: function () {
return this.name || 'nora config';
},
password: {
type: 'text'
oneditprepare: function () {
$('#node-config-input-twofactor').change(function () {
if ($(this).val() === 'pin') {
$('#node-config-twofactor-pin').show();
} else {
$('#node-config-twofactor-pin').hide();
}
});

$('#sso-login').on('click', function () {
const features = [
'popup=yes',
'scrollbars=yes',
'resizable=yes',
'width=410',
'height=600',
].join(',');
if (ssoWindow === null || ssoWindow.closed) {
ssoWindow = window.open(`${noraOrigin}/my-nora/sso?ref=${encodeURIComponent(location.origin)}`, '_blank', features);
} else {
ssoWindow.focus();
}
});

handler = (msg) => {
if (msg.type === 'nora-sso-token') {
$('#node-config-input-email').val(msg.email);
$('#node-config-input-password').val('');
$('#node-config-input-sso').val(msg.token);
}
};
},
},
paletteLabel: 'nora config',
label: function () {
return this.name || 'nora config';
},
oneditprepare: function () {
$('#node-config-input-twofactor').change(function () {
if ($(this).val() === 'pin') {
$('#node-config-twofactor-pin').show();
} else {
$('#node-config-twofactor-pin').hide();
}
});
},
});
});
})();
</script>

<script type="text/x-red" data-template-name="noraf-config">
Expand All @@ -55,11 +89,12 @@
</div>
<div class="form-row">
<label for="node-config-input-email"><i class="fa fa-envelope"></i> Email</label>
<input type="text" id="node-config-input-email">
<input type="text" id="node-config-input-email" readonly>
</div>
<div class="form-row">
<label for="node-config-input-password"><i class="fa fa-key"></i> Password</label>
<input type="password" id="node-config-input-password">
<button id="sso-login" class="ui-button ui-corner-all ui-widget primary">Login</button>
<input type="hidden" id="node-config-input-password">
<input type="hidden" id="node-config-input-sso">
</div>
<div class="form-row">
<label for="node-config-input-structure"><i class="fa fa-home"></i> Home name</label>
Expand Down
4 changes: 3 additions & 1 deletion src/nodes/nora-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ module.exports = function (RED: any) {
RED.nodes.createNode(this, config);
this.email = this.credentials && this.credentials.email;
this.password = this.credentials && this.credentials.password;
this.sso = this.credentials && this.credentials.sso;
this.group = (config.group || '<default>').trim();
this.valid = !!this.email?.length && !!this.password?.length;
this.valid = !!this.email?.length && (!!this.password?.length || !!this.sso?.length);
this.localExecution = config.localexecution ?? true;
this.storeStateInContext = config.storeStateInContext ?? false;

Expand All @@ -27,6 +28,7 @@ module.exports = function (RED: any) {
credentials: {
email: { type: 'text' },
password: { type: 'text' },
sso: { type: 'text' },
},
});
};
Expand Down
1 change: 0 additions & 1 deletion src/nodes/nora-sprinkler.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@
node.startStopZones = [];
zones.each(function (i) {
zone = zones[i];
console.log(zone);
node.startStopZones.push(zone.find(".node-input-zone").val().trim());
});
}
Expand Down
76 changes: 64 additions & 12 deletions src/nora/connection.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { createHash } from 'crypto';
import { deleteApp, FirebaseApp, initializeApp } from 'firebase/app';
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';

import { getAuth, signInWithEmailAndPassword, signInWithCustomToken, UserCredential } from 'firebase/auth';
import { merge, Observable, of, timer } from 'rxjs';
import { delayWhen, finalize, ignoreElements, map, retryWhen, switchMap, tap } from 'rxjs/operators';
import { Logger, publishReplayRefCountWithDelay } from '..';
import { firebaseConfig, NoraConfig } from '../config';
import fetch from 'node-fetch';

import { HttpError, Logger, publishReplayRefCountWithDelay, shouldRetryRequest } from '..';
import { API_ENDPOINT, FIREBASE_CONFIG, NoraConfig, USER_AGENT } from '../config';
import { AsyncCommandsRegistry } from './async-commands.registry';
import { DeviceContext } from './device-context';
import { LocalExecution } from './local-execution';
Expand All @@ -31,7 +33,7 @@ export class FirebaseConnection {
static fromConfig(
config: NoraConfig,
ctx: DeviceContext) {
const key = this.getConfigKey(config);
const key = this.getHash(`${config.email}:${config.group}:${config.password || config.sso}`);
let cached = this.configs[key];
if (!cached) {
cached = this.configs[key] = this.getAppFromConfig(config)
Expand All @@ -58,17 +60,13 @@ export class FirebaseConnection {
);
}

private static getConfigKey(config: NoraConfig) {
return `${config.email}:${config.group}:${config.password}`;
}

private static getAppFromConfig(config: NoraConfig) {
const key = `${config.email}:${config.password}`;
const key = this.getHash(`${config.email}:${config.password || config.sso}`);
let cached = this.apps[key];
if (!cached) {
cached = this.apps[key] = this.createFirebaseApp().pipe(
switchMap(async app => {
const result = await signInWithEmailAndPassword(getAuth(app), config.email, config.password);
const result = await this.authenticate(app, config);
this.logger?.info(`nora: authenticated, uid: ${result.user?.uid}`);
return app;
}),
Expand All @@ -79,9 +77,63 @@ export class FirebaseConnection {
return cached;
}

private static getHash(input: string): string {
return createHash('md5').update(input).digest('base64');
}

private static async authenticate(app: FirebaseApp, config: NoraConfig): Promise<UserCredential> {
const auth = getAuth(app);

try {
if (config.password?.length) {
return await signInWithEmailAndPassword(auth, config.email, config.password);
} else if (config.sso?.length) {
const customToken = await this.exchangeToken(config.sso);
return await signInWithCustomToken(auth, customToken);
} else {
throw new Error('nora: invalid auth config');
}
} catch (err) {
this.logger?.error(`nora: ${err}`);
await new Promise<never>(() => {
// never resolve, there's nothing to retry
});
throw new Error(); // make TS happy :)
}
}

private static async exchangeToken(ssoToken: string, tries = 3): Promise<string> {
while (tries--) {
const url = `${API_ENDPOINT}/sso/exchange`;
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'user-agent': USER_AGENT,
},
body: JSON.stringify({
token: ssoToken
}),
});
if (response.status !== 200) {
const shouldRetry = shouldRetryRequest(response);
if (!shouldRetry || !tries) {
throw new HttpError(response.status, await response.text());
}
const delay = Math.round(Math.random() * 20) * 50 + 300;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
const json = await response.json();
return json.token;
}

throw new Error('could not exchange sso token');
}

private static createFirebaseApp() {
return new Observable<FirebaseApp>(observer => {
const app = initializeApp(firebaseConfig, `app-${new Date().getTime()}`);
const app = initializeApp(FIREBASE_CONFIG, `app-${new Date().getTime()}`);
observer.next(app);
return () => deleteApp(app);
});
Expand Down
Loading

0 comments on commit d4d2eb4

Please sign in to comment.