[next] Auth Popup v2 (#2101)

* feat: Implement new Sign In view

* feat: Move forgot + resetPassword to new design

* feat: Implement sign up with new design

* fix: narrow gutter

* test: add unit tests

* test: integration tests

* feat: support show / hide password

* feat: support oauth2 flow

* feat: add views for user completion

* feat: implement oauth2 sign up

* test: fix snapshots

* fix: lint

* fix: get more complete mutation response

* fix: removed array of OIDC integrations

* fix: renamed resolver function

* fix: adapt oidc client implementation

* fix: targetFilter should be stream on signup

* fix: removed unneeded message

* fix: moved password into local profile

* fix: made username optional, removed valid null value

* fix: linting

* fix: respect targetFilter

* feat: support user registration mutations

- Added `setUsername`
- Added `setEmail`
- Added `setPassword`
- Added `permit` to `@auth`
- Added `email` to `User`

* fix: fixed issue with query

* feat: added user password update

* feat: complete sign in mutation

* fix: adapt some rebasing gitches

* test: improve tests

* test: unittest for setting auth token

* fix: failing tests

* test: move most tests from enzyme to react-test-renderer

* fix: remove schema warnings in tests

* test: improve window mock

* test: test different social login configurations

* test: test social logins for sign up

* fix: use htmlFor instead of for

* test: more feature tests

* feat: always go through account completion

* test: feature test account completion

* feat: addtional account completion test

* Update start.ts

* chore: refactor auth token retrieval logic
This commit is contained in:
Kiwi
2018-12-20 22:32:04 +01:00
committed by GitHub
parent 326a10dc5d
commit 065cb4b03a
331 changed files with 12476 additions and 7163 deletions
@@ -67,13 +67,13 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async (
const profile: LocalProfile = {
id: email,
type: "local",
password,
};
// Create the new user.
const user = await upsert(options.db, tenant, {
email,
username,
password,
profiles: [profile],
// New users signing up via local auth will have the commenter role to
// start with.
@@ -14,7 +14,7 @@ import { Request } from "talk-server/types/express";
export interface TenantInstallBody {
tenant: Omit<InstallTenant, "domain">;
user: Required<Pick<UpsertUser, "username" | "email" | "password">>;
user: Required<Pick<UpsertUser, "username" | "email"> & { password: string }>;
}
const TenantInstallBodySchema = Joi.object().keys({
@@ -79,15 +79,15 @@ export const tenantInstallHandler = ({
// Configure with profile.
const profile: LocalProfile = {
id: email,
type: "local",
id: email,
password,
};
// Create the first admin user.
await upsert(mongo, tenant, {
email,
username,
password,
profiles: [profile],
role: GQLUSER_ROLE.ADMIN,
});
@@ -137,6 +137,90 @@ export async function handleSuccessfulLogin(
}
}
export async function handleOAuth2Callback(
user: User,
signingConfig: JWTSigningConfig,
req: Request,
res: Response,
next: NextFunction
) {
try {
// Talk is guaranteed at this point.
const { tenant } = req.talk!;
const options: SigningTokenOptions = {};
if (tenant) {
// Attach the tenant's id to the issued token as a `iss` claim.
options.issuer = tenant.id;
}
// Grab the token.
const token = await signTokenString(signingConfig, user, options);
// Set the cache control headers.
res.header("Cache-Control", "private, no-cache, no-store, must-revalidate");
res.header("Expires", "-1");
res.header("Pragma", "no-cache");
// Send back the details!
res.send(
`<html>
<head></head>
<body>
<script type="text/javascript">
const redirect = sessionStorage.getItem("authRedirectBackTo");
if (!redirect) {
var textnode = document.createTextNode("'authRedirectBackTo' not set in Session Storage");
document.body.appendChild(textnode);
}
else if (redirect[0] !== '/') {
var textnode = document.createTextNode("'authRedirectBackTo' must begin with '/'");
document.body.appendChild(textnode);
}
else {
location.href = \`\${redirect}#${token}\`;
}
</script>
</body>
</html>`
);
} catch (err) {
return next(err);
}
}
/**
* wrapCallbackAuthn will wrap a authenticators authenticate method with one that
* will render a redirect script for a valid login by a compatible strategy.
*
* @param authenticator the base authenticator instance
* @param signingConfig used to sign the tokens that are issued.
* @param name the name of the authenticator to use
* @param options any options to be passed to the authenticate call
*/
export const wrapOAuth2Authn = (
authenticator: passport.Authenticator,
signingConfig: JWTSigningConfig,
name: string,
options?: any
): RequestHandler => (req: Request, res, next) =>
authenticator.authenticate(
name,
{ ...options, session: false },
(err: Error | null, user: User | null) => {
if (err) {
return next(err);
}
if (!user) {
// TODO: (wyattjoh) replace with better error.
return next(new Error("no user on request"));
}
handleOAuth2Callback(user, signingConfig, req, res, next);
}
)(req, res, next);
/**
* wrapAuthn will wrap a authenticators authenticate method with one that
* will return a valid login token for a valid login by a compatible strategy.
@@ -67,7 +67,6 @@ export default class FacebookStrategy extends OAuth2Strategy<
}
user = await upsert(this.mongo, tenant, {
username: null,
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
@@ -80,7 +80,6 @@ export default class GoogleStrategy extends OAuth2Strategy<
}
user = await upsert(this.mongo, tenant, {
username: null,
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
@@ -4,7 +4,7 @@ import { Strategy } from "passport-strategy";
import { Profile } from "passport";
import { VerifyCallback } from "passport-oauth2";
import { Config } from "talk-server/config";
import { GQLAuthIntegrations } from "talk-server/graph/tenant/schema/__generated__/types";
import { AuthIntegrations } from "talk-server/models/settings";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
@@ -42,7 +42,7 @@ export default abstract class OAuth2Strategy<
this.scope = scope;
}
protected abstract getIntegration(integrations: GQLAuthIntegrations): T;
protected abstract getIntegration(integrations: AuthIntegrations): T;
protected abstract createStrategy(
tenant: Tenant,
@@ -39,13 +39,10 @@ export interface OIDCIDToken {
nickname?: string;
}
export type StrategyItem = Record<
string,
{
strategy: OAuth2Strategy;
jwksClient?: JwksClient;
}
>;
export interface StrategyItem {
strategy: OAuth2Strategy;
jwksClient?: JwksClient;
}
export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken {
if (
@@ -88,24 +85,10 @@ const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => (
};
function getEnabledIntegration(
tenant: Tenant,
oidcID: string
tenant: Tenant
): Required<GQLOIDCAuthIntegration> {
if (!tenant.auth.integrations.oidc) {
// TODO: return a better error.
throw new Error("integration not found");
}
// Grab the OIDC Integration from the list of integrations.
const integration = tenant.auth.integrations.oidc.find(
({ id }) => id === oidcID
);
if (!integration) {
// TODO: return a better error.
throw new Error("integration not found");
}
// Handle when the integration is enabled/disabled.
// Grab the OIDC Integration.
const integration = tenant.auth.integrations.oidc;
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
@@ -181,7 +164,6 @@ export async function findOrCreateOIDCUser(
// Create the new user, as one didn't exist before!
user = await upsert(db, tenant, {
username: null,
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
@@ -224,24 +206,19 @@ export default class OIDCStrategy extends Strategy {
tenantID: string,
oidc: Required<GQLOIDCAuthIntegration>
): jwks.JwksClient {
let tenantIntegrations = this.cache.get(tenantID);
if (!tenantIntegrations || !tenantIntegrations[oidc.id]) {
let tenantIntegration = this.cache.get(tenantID);
if (!tenantIntegration) {
const strategy = this.createStrategy(req, oidc);
// Create the entry.
tenantIntegrations = {
[oidc.id]: {
strategy,
},
...(tenantIntegrations || {}),
tenantIntegration = {
strategy,
};
// We don't reset the entry in the cache here because if we just created
// it, we'll be creating the jwksClient anyways, so we'll update it there.
}
const tenantIntegration = tenantIntegrations[oidc.id];
if (!tenantIntegration.jwksClient) {
// Create the new JWKS client.
const jwksClient = jwks({
@@ -252,13 +229,7 @@ export default class OIDCStrategy extends Strategy {
tenantIntegration.jwksClient = jwksClient;
// Update the cached entry.
this.cache.set(tenantID, {
[oidc.id]: {
...tenantIntegration,
jwksClient,
},
...tenantIntegrations,
});
this.cache.set(tenantID, tenantIntegration);
}
return tenantIntegration.jwksClient;
@@ -287,14 +258,11 @@ export default class OIDCStrategy extends Strategy {
return done(new Error("tenant not found"));
}
// Grab the OIDC ID from the request.
const { oidcID }: { oidcID: string } = req.params;
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
let integration: Required<GQLOIDCAuthIntegration>;
try {
integration = getEnabledIntegration(tenant, oidcID);
integration = getEnabledIntegration(tenant);
} catch (err) {
// TODO: wrap error?
return done(err);
@@ -338,10 +306,7 @@ export default class OIDCStrategy extends Strategy {
const { clientID, clientSecret, authorizationURL, tokenURL } = integration;
// Construct the callbackURL from the request.
const callbackURL = reconstructURL(
req,
`/api/tenant/auth/oidc/${integration.id}/callback`
);
const callbackURL = reconstructURL(req, `/api/tenant/auth/oidc/callback`);
// Create a new OAuth2Strategy, where we pass the verify callback bound to
// this OIDCStrategy instance.
@@ -365,32 +330,26 @@ export default class OIDCStrategy extends Strategy {
throw new Error("tenant not found");
}
// Get the OIDC ID.
const { oidcID }: { oidcID: string } = req.params;
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
const integration = getEnabledIntegration(tenant, oidcID);
const integration = getEnabledIntegration(tenant);
// Try to get the Tenant's cached integrations.
let tenantIntegrations = this.cache.get(tenant.id);
if (!tenantIntegrations || !tenantIntegrations[oidcID]) {
let tenantIntegration = this.cache.get(tenant.id);
if (!tenantIntegration) {
// Create the strategy.
const strategy = this.createStrategy(req, integration);
// Reset the entry.
tenantIntegrations = {
[oidcID]: {
strategy,
},
...(tenantIntegrations || {}),
tenantIntegration = {
strategy,
};
// Update the cached integrations value.
this.cache.set(tenant.id, tenantIntegrations);
this.cache.set(tenant.id, tenantIntegration);
}
return tenantIntegrations[oidcID].strategy;
return tenantIntegration.strategy;
}
public authenticate(req: Request) {
@@ -18,7 +18,7 @@ export interface SSOStrategyOptions {
export interface SSOUserProfile {
id: string;
email: string;
username: string;
username?: string;
avatar?: string;
displayName?: string;
}
+10 -3
View File
@@ -5,7 +5,10 @@ import {
logoutHandler,
signupHandler,
} from "talk-server/app/handlers/api/tenant/auth/local";
import { wrapAuthn } from "talk-server/app/middleware/passport";
import {
wrapAuthn,
wrapOAuth2Authn,
} from "talk-server/app/middleware/passport";
import { RouterOptions } from "talk-server/app/router/types";
function wrapPath(
@@ -15,7 +18,11 @@ function wrapPath(
strategy: string,
path: string = `/${strategy}`
) {
const handler = wrapAuthn(options.passport, app.signingConfig, strategy);
const handler = wrapOAuth2Authn(
options.passport,
app.signingConfig,
strategy
);
router.get(path, handler);
router.get(path + "/callback", handler);
@@ -46,7 +53,7 @@ export function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
// Mount the external auth integrations with middleware/handle wrappers.
wrapPath(app, options, router, "facebook");
wrapPath(app, options, router, "google");
wrapPath(app, options, router, "oidc", "/oidc/:oidc");
wrapPath(app, options, router, "oidc");
return router;
}
+1 -1
View File
@@ -23,7 +23,7 @@ export function constructTenantURL(
): string {
let url: URL = new URL(path, `https://${tenant.domain}`);
if (config.get("env") === "development") {
url = new URL(path, `http://${tenant.domain}:${config.get("port")}`);
url = new URL(path, `http://${tenant.domain}:${config.get("dev_port")}`);
}
return url.href;