mirror of
https://github.com/wassname/talk.git
synced 2026-07-25 13:30:59 +08:00
[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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user