Skip to content
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

refactor: upgrade to use sveltekit 1.0 #36

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ GUEST_DEMO_API_URL=https://wxsd.wbx.ninja/wxsd-guest-demo
GUEST_DEMO_CREATE_ENDPOINT=create_url
IMI_WEBHOOK_URL=https://hooks-us.imiconnect.io/events
IMI_SMS_ENDPOINT=
MICROSOFT_API_URL=https://graph.microsoft.com/v1.0
MICROSOFT_OAUTH_URL=https://login.microsoftonline.com
MICROSOFT_OAUTH_TOKEN_ENDPOINT=oauth2/v2.0/token
MICROSOFT_CLIENT_CREDENTIALS_SCOPE=https://graph.microsoft.com/.default
MICROSOFT_CLIENT_CREDENTIALS_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
MIKRO_ORM_DYNAMIC_IMPORTS=true
OPENWEATHERMAP_API_URL=https://api.openweathermap.org/data/2.5
OPENWEATHERMAP_API_KEY=
Expand Down
6 changes: 4 additions & 2 deletions src/components/Authorized/Authorized.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<script lang="ts">
import type { TokenResponse } from './types/token-response';
import type { PersonResponse } from '$lib/types';
import { writable } from 'svelte/store';
import Person from '../Person/Person.svelte';

export let tokenResponseStore = writable<TokenResponse>(undefined);
export let personStore = writable<PersonResponse>(undefined);

let isActive = false;
</script>
Expand All @@ -13,7 +15,7 @@
<div class="navbar-item dropdown is-hoverable avatar-navbar-brand-item">
<div class="dropdown-trigger title mb-0">
{#if $tokenResponseStore?.accessToken}
<Person accessToken={$tokenResponseStore.accessToken} bind:id={$tokenResponseStore.id} />
<Person accessToken={$tokenResponseStore.accessToken} bind:id={$tokenResponseStore.id} {personStore} />
{/if}
</div>
<div class="dropdown-menu" role="menu">
Expand Down Expand Up @@ -41,7 +43,7 @@
<slot name="navbar-button" />
<button
class="button is-rounded is-danger is-medium is-flex-grow-1 has-text-weight-bold"
on:click={() => tokenResponseStore.set(undefined)}
on:click={() => tokenResponseStore.set(undefined) || personStore.set(undefined)}
>
<span class="icon">
<i class="mdi mdi-logout" />
Expand Down
4 changes: 2 additions & 2 deletions src/components/Events/Events.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@
</Event>
{/each}
{#if event.length === 0}
<p class="subtitle">No upcoming Google Calendar events.</p>
<p class="subtitle">No upcoming calendar events.</p>
{/if}
{:catch error}
<p class="subtitle has-text-danger" title={error?.message}>Could not get Google Calendar events.</p>
<p class="subtitle has-text-danger" title={error?.message}>Could not get calendar events.</p>
{/await}
</div>
<slot name="footer" />
Expand Down
4 changes: 2 additions & 2 deletions src/components/Person/Person.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import type { PersonResponse } from './types/person-response';
import type { PersonResponse } from '$lib/types';
import { webexHttpPeopleResource } from '$lib/webex/http-wrapper';
import { browser } from '$app/env';
import { onMount } from 'svelte';
Expand All @@ -12,6 +12,7 @@
export let accessToken: string;
export let size = 64;
export let rtf = new Intl.RelativeTimeFormat('en', { style: 'narrow' });
export let personStore = writable<PersonResponse>(undefined);

export const units = {
year: 24 * 60 * 60 * 1000 * 365,
Expand Down Expand Up @@ -41,7 +42,6 @@
: webexHttpPeopleResource(accessToken).getMyOwnDetails()
).then((r) => r.json() as Promise<PersonResponse>);

let personStore = writable<PersonResponse>(undefined);
let personResponse = browser ? getPersonDetails(accessToken, id) : Promise.resolve(undefined);

$: personResponse?.then((r) => id ?? (id = r.id));
Expand Down
20 changes: 20 additions & 0 deletions src/database/entities/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,29 @@ export class Activation extends BaseEntity {
@Property({ type: types.string, nullable: true })
googleClientCertificate?: string;

@Property({ type: types.string, nullable: true })
microsoftTenantId?: string;

@Property({ type: types.string, nullable: true })
microsoftClientId?: string;

@Property({ type: types.string, nullable: true })
microsoftPrivateKey?: string;

@Property({ type: types.string, nullable: true })
microsoftClientCertificate?: string;

constructor(obj: {
botToken: string;
deviceId: string;
demo: Demo;
googleClientEmail?: string;
googlePrivateKey?: string;
googleClientCertificate?: string;
microsoftTenantId?: string;
microsoftClientId?: string;
microsoftPrivateKey?: string;
microsoftClientCertificate?: string;
}) {
super();
this.botToken = obj.botToken;
Expand All @@ -38,5 +54,9 @@ export class Activation extends BaseEntity {
this.googleClientEmail = obj.googleClientEmail;
this.googlePrivateKey = obj.googlePrivateKey;
this.googleClientCertificate = obj.googleClientCertificate;
this.microsoftTenantId = obj.microsoftTenantId;
this.microsoftClientId = obj.microsoftClientId;
this.microsoftPrivateKey = obj.microsoftPrivateKey;
this.microsoftClientCertificate = obj.microsoftClientCertificate;
}
}
36 changes: 36 additions & 0 deletions src/database/migrations/.snapshot-app.sqlite3.json
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,42 @@
"primary": false,
"nullable": true,
"mappedType": "text"
},
"microsoft_tenant_id": {
"name": "microsoft_tenant_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"microsoft_client_id": {
"name": "microsoft_client_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"microsoft_private_key": {
"name": "microsoft_private_key",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"microsoft_client_certificate": {
"name": "microsoft_client_certificate",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
}
},
"name": "activation",
Expand Down
10 changes: 10 additions & 0 deletions src/database/migrations/migration-20221208000000.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Migration } from '@mikro-orm/migrations';

export class Migration20221208000000 extends Migration {
async up(): Promise<void> {
this.addSql('alter table `activation` add column `microsoft_tenant_id` text null;');
this.addSql('alter table `activation` add column `microsoft_client_id` text null;');
this.addSql('alter table `activation` add column `microsoft_private_key` text null;');
this.addSql('alter table `activation` add column `microsoft_client_certificate` text null;');
}
}
20 changes: 20 additions & 0 deletions src/lib/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ export class Environment {
@IsNotEmpty()
public readonly IMI_SMS_ENDPOINT: string;

@IsUrl()
public readonly MICROSOFT_API_URL: string;

@IsUrl()
public readonly MICROSOFT_OAUTH_URL: string;

@IsNotEmpty()
public readonly MICROSOFT_OAUTH_TOKEN_ENDPOINT: string;

@IsNotEmpty()
public readonly MICROSOFT_CLIENT_CREDENTIALS_SCOPE: string;

@IsNotEmpty()
public readonly MICROSOFT_CLIENT_CREDENTIALS_TYPE: string;

@IsUrl()
public readonly OPENWEATHERMAP_API_URL: string;

Expand Down Expand Up @@ -138,6 +153,11 @@ export class Environment {
this.GUEST_DEMO_CREATE_ENDPOINT = process.env.GUEST_DEMO_CREATE_ENDPOINT as string;
this.IMI_WEBHOOK_URL = process.env.IMI_WEBHOOK_URL as string;
this.IMI_SMS_ENDPOINT = process.env.IMI_SMS_ENDPOINT as string;
this.MICROSOFT_API_URL = process.env.MICROSOFT_API_URL as string;
this.MICROSOFT_OAUTH_URL = process.env.MICROSOFT_OAUTH_URL as string;
this.MICROSOFT_OAUTH_TOKEN_ENDPOINT = process.env.MICROSOFT_OAUTH_TOKEN_ENDPOINT as string;
this.MICROSOFT_CLIENT_CREDENTIALS_SCOPE = process.env.MICROSOFT_CLIENT_CREDENTIALS_SCOPE as string;
this.MICROSOFT_CLIENT_CREDENTIALS_TYPE = process.env.MICROSOFT_CLIENT_CREDENTIALS_TYPE as string;
this.OPENWEATHERMAP_API_URL = process.env.OPENWEATHERMAP_API_URL as string;
this.OPENWEATHERMAP_API_KEY = process.env.OPENWEATHERMAP_API_KEY as string;
this.WEBEX_API_URL = process.env.WEBEX_API_URL as string;
Expand Down
163 changes: 163 additions & 0 deletions src/routes/api/client-credentials/microsoft/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import 'reflect-metadata';
import type { RequestEvent, RequestHandler } from '@sveltejs/kit';
import type { JSONObject, JSONValue, ToJSON } from '@sveltejs/kit/types/private';
import { urlEncodedRequest } from '$lib/shared/urlencoded-request';
import { Expose, instanceToPlain, plainToInstance, Transform } from 'class-transformer';
import { IsJWT, IsNotEmpty, IsUUID, Matches, validateSync } from 'class-validator';
import { classTransformOptions, classValidationOptions } from '../../../.utils';
import { VALID_ACCESS_TOKEN } from '$lib/constants';
import { webexHttpPeopleResource } from '$lib/webex/http-wrapper';
import { MikroORM } from '@mikro-orm/core';
import { Activation } from '../../../../database/entities';
import config from '../../../../../mikro-orm.config';
import humps from 'humps';
import env from '$lib/environment';
import * as jose from 'jose';
import { base } from '$app/paths';

/** @typedef {import('class-validator').ValidationError} ValidationError */

const onFailure = async (e: Response | Error) => {
if (e instanceof Response && e.status !== 401) {
return e.status === 428
? { status: e.status, body: await e.json(), headers: { 'Skip-Reporting': true } }
: { status: e.status, body: await e.json() };
} else {
console.log(e);
return { status: 500 };
}
};

/**
* @param {RequestEvent} requestEvent
*
* @returns {Promise<
* | { body: { query: ValidationError[] }; status: 400 }
* | { body: ResponseDTO; status: 200 }
* | { body: any; status: number }
* | { status: 500 }
* >}
*/
export const POST = async (requestEvent: RequestEvent) => {
class RequestQueryDTO {
@Expose()
@IsNotEmpty()
@IsUUID(4)
public readonly activationId!: string;
}

class RequestHeaderDTO {
@Expose()
@IsNotEmpty()
@Transform(({ obj }) => obj?.authorization?.toString()?.replace(/^Bearer /i, ''))
public readonly authorization!: string;
}

class ResponseDTO implements ToJSON {
@Expose()
@Transform(({ obj, value }) => value ?? obj.expires_in, { toClassOnly: true })
public readonly expiresIn!: number;

@Expose()
@Transform(({ value }) => new Date(value).toUTCString(), { toPlainOnly: true })
public readonly expiresAt?: string;

@Expose()
@Transform(({ obj, value }) => value ?? obj.token_type, { toClassOnly: true })
public readonly tokenType!: string;

@Expose()
@Transform(({ obj, value }) => value ?? obj.access_token, { toClassOnly: true })
public readonly accessToken!: string;

toJSON(): Exclude<JSONValue, ToJSON> {
return instanceToPlain(this);
}
}

// validate request query
const searchParams = Object.fromEntries(requestEvent.url.searchParams);
const query = plainToInstance(RequestQueryDTO, searchParams, classTransformOptions);
const queryValidationErrors = validateSync(query, classValidationOptions);
if (queryValidationErrors.length > 0) {
return { status: 400, body: { query: queryValidationErrors } };
}

// validate request header
const obj = humps.camelizeKeys(Object.fromEntries(requestEvent.request.headers.entries()));
const header = plainToInstance(RequestHeaderDTO, obj, classTransformOptions);
const headerValidationErrors = validateSync(header, classValidationOptions);
if (headerValidationErrors.length > 0) {
return { status: 401 };
}

const orm = await MikroORM.init({ ...config, ...{ entities: [Activation] } });
const em = orm.em.fork();

const subject = webexHttpPeopleResource(header.authorization)
.getMyOwnDetails()
.then((r) => r.json())
.then((r) => r.emails[0]);

const account = em.findOneOrFail(Activation, query.activationId, {
fields: ['microsoftTenantId', 'microsoftClientId', 'microsoftPrivateKey', 'microsoftClientCertificate']
});

const formJwt = (audience: string, subject: string, issuer: string, pkcs8: string, x509: string, expiry = '1h') => {
const alg = 'RS256';
const data = x509
.replaceAll('\n', '')
?.match(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gm)
?.join('');
const sha1 = crypto.subtle.digest('SHA-1', Buffer.from(data as string, 'base64'));
const hex = sha1.then((r) => Buffer.from(r).toString('hex'));
const x5t = hex.then((r) => Buffer.from(r, 'hex').toString('base64url'));

return Promise.all([x5t, jose.importPKCS8(pkcs8, alg)]).then(([r1, r2]) =>
new jose.SignJWT({})
.setProtectedHeader({ alg, x5t: r1 })
.setIssuer(issuer)
.setAudience(audience)
.setSubject(subject)
.setIssuedAt()
.setExpirationTime(expiry)
.sign(r2)
);
};

const addExpiresAt = (obj: JSONObject, date: number = Date.now()) => ({
...obj,
...{ expiresAt: date + (obj.expiresIn as number) * 1000 }
});

return Promise.all([subject, account])
.then(([, r2]) =>
formJwt(
env.MICROSOFT_OAUTH_URL + `/${r2.microsoftTenantId}/` + env.MICROSOFT_OAUTH_TOKEN_ENDPOINT,
r2.microsoftClientId as string,
r2.microsoftClientId as string,
r2.microsoftPrivateKey as string,
r2.microsoftClientCertificate as string
).then((r) =>
urlEncodedRequest(env.MICROSOFT_OAUTH_URL).post(
r2.microsoftTenantId + '/' + env.MICROSOFT_OAUTH_TOKEN_ENDPOINT,
undefined,
humps.decamelizeKeys({
scope: env.MICROSOFT_CLIENT_CREDENTIALS_SCOPE,
clientId: r2.microsoftClientId,
clientAssertionType: env.MICROSOFT_CLIENT_CREDENTIALS_TYPE,
clientAssertion: r,
grantType: 'client_credentials'
}) as JSONObject
)
)
)
.then((r) =>
r
.json()
.then((s: JSONObject) => humps.camelizeKeys(s) as JSONObject)
.then((s: JSONObject) => addExpiresAt(s, Date.parse(r.headers.get('date') ?? new Date().toUTCString())))
)
.then((r) => ({ status: 200, body: plainToInstance(ResponseDTO, r, classTransformOptions) }))
.catch((e) => onFailure(e));
};
Loading