mirror of
https://github.com/wassname/talk.git
synced 2026-08-19 12:40:16 +08:00
[next] Auth (#2257)
* feat: improved auth features + performance * fix: auth check logic * fix: tests
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
FacebookProfile,
|
||||
retrieveUserWithProfile,
|
||||
} from "talk-server/models/user";
|
||||
import { upsert } from "talk-server/services/users";
|
||||
import { insert } from "talk-server/services/users";
|
||||
|
||||
export type FacebookStrategyOptions = OAuth2StrategyOptions;
|
||||
|
||||
@@ -71,7 +71,7 @@ export default class FacebookStrategy extends OAuth2Strategy<
|
||||
emailVerified = false;
|
||||
}
|
||||
|
||||
user = await upsert(this.mongo, tenant, {
|
||||
user = await insert(this.mongo, tenant, {
|
||||
username: displayName,
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
GoogleProfile,
|
||||
retrieveUserWithProfile,
|
||||
} from "talk-server/models/user";
|
||||
import { upsert } from "talk-server/services/users";
|
||||
import { insert } from "talk-server/services/users";
|
||||
|
||||
export type GoogleStrategyOptions = OAuth2StrategyOptions;
|
||||
|
||||
@@ -70,7 +70,7 @@ export default class GoogleStrategy extends OAuth2Strategy<
|
||||
emailVerified = false;
|
||||
}
|
||||
|
||||
user = await upsert(this.mongo, tenant, {
|
||||
user = await insert(this.mongo, tenant, {
|
||||
username: displayName,
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
|
||||
@@ -2,35 +2,31 @@ import jwt from "jsonwebtoken";
|
||||
import { Strategy } from "passport-strategy";
|
||||
|
||||
import { AppOptions } from "talk-server/app";
|
||||
import {
|
||||
JWTToken,
|
||||
JWTVerifier,
|
||||
} from "talk-server/app/middleware/passport/strategies/verifiers/jwt";
|
||||
import {
|
||||
SSOToken,
|
||||
SSOVerifier,
|
||||
} from "talk-server/app/middleware/passport/strategies/verifiers/sso";
|
||||
import { TenantNotFoundError, TokenInvalidError } from "talk-server/errors";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { User } from "talk-server/models/user";
|
||||
import { extractJWTFromRequest } from "talk-server/services/jwt";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
import { JWTToken, JWTVerifier } from "./verifiers/jwt";
|
||||
import { OIDCIDToken, OIDCVerifier } from "./verifiers/oidc";
|
||||
import { SSOToken, SSOVerifier } from "./verifiers/sso";
|
||||
|
||||
export type JWTStrategyOptions = Pick<
|
||||
AppOptions,
|
||||
"signingConfig" | "mongo" | "redis"
|
||||
"signingConfig" | "mongo" | "redis" | "tenantCache"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Token is the various forms of the Token that can be verified.
|
||||
*/
|
||||
type Token = SSOToken | JWTToken | object | string | null;
|
||||
type Token = OIDCIDToken | SSOToken | JWTToken | object | string | null;
|
||||
|
||||
/**
|
||||
* Verifier allows different implementations to offer ways to verify a given
|
||||
* Token.
|
||||
*/
|
||||
interface Verifier<T> {
|
||||
export interface Verifier<T = Token> {
|
||||
/**
|
||||
* verify will perform the verification and return a User.
|
||||
*/
|
||||
@@ -50,18 +46,16 @@ interface Verifier<T> {
|
||||
export class JWTStrategy extends Strategy {
|
||||
public name = "jwt";
|
||||
|
||||
private verifiers: {
|
||||
sso: Verifier<SSOToken>;
|
||||
jwt: Verifier<JWTToken>;
|
||||
};
|
||||
private verifiers: Verifier[];
|
||||
|
||||
constructor(options: JWTStrategyOptions) {
|
||||
super();
|
||||
|
||||
this.verifiers = {
|
||||
sso: new SSOVerifier(options),
|
||||
jwt: new JWTVerifier(options),
|
||||
};
|
||||
this.verifiers = [
|
||||
new OIDCVerifier(options),
|
||||
new SSOVerifier(options),
|
||||
new JWTVerifier(options),
|
||||
];
|
||||
}
|
||||
|
||||
private async verify(tokenString: string, tenant: Tenant) {
|
||||
@@ -70,22 +64,11 @@ export class JWTStrategy extends Strategy {
|
||||
throw new TokenInvalidError(tokenString, "token could not be decoded");
|
||||
}
|
||||
|
||||
// TODO: add OIDC support.
|
||||
// At the moment, OpenID Connect tokens are not supported here directly,
|
||||
// instead, the default implementation redirects the user to the
|
||||
// authorization endpoint where they login, and a redirection occurs
|
||||
// yielding the token to us via the Authorization Code Flow. We then issue a
|
||||
// Talk Token for that request, that the client uses after.
|
||||
|
||||
// Handle SSO integrations.
|
||||
if (this.verifiers.sso.supports(token, tenant)) {
|
||||
return this.verifiers.sso.verify(tokenString, token, tenant);
|
||||
}
|
||||
|
||||
// Handle the raw JWT token.
|
||||
if (this.verifiers.jwt.supports(token, tenant)) {
|
||||
// Verify the token with the JWT verification strategy.
|
||||
return this.verifiers.jwt.verify(tokenString, token, tenant);
|
||||
// Try to verify the token.
|
||||
for (const verifier of this.verifiers) {
|
||||
if (verifier.supports(token, tenant)) {
|
||||
return verifier.verify(tokenString, token, tenant);
|
||||
}
|
||||
}
|
||||
|
||||
// No verifier could be found.
|
||||
|
||||
@@ -7,15 +7,18 @@ import { Strategy } from "passport-strategy";
|
||||
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { reconstructURL } from "talk-server/app/url";
|
||||
import {
|
||||
GQLOIDCAuthIntegration,
|
||||
GQLUSER_ROLE,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "talk-server/logger";
|
||||
import { OIDCAuthIntegration } from "talk-server/models/settings";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user";
|
||||
import {
|
||||
OIDCProfile,
|
||||
retrieveUserWithProfile,
|
||||
User,
|
||||
} from "talk-server/models/user";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
|
||||
import { upsert } from "talk-server/services/users";
|
||||
import { insert } from "talk-server/services/users";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export interface Params {
|
||||
@@ -85,11 +88,9 @@ const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => (
|
||||
});
|
||||
};
|
||||
|
||||
function getEnabledIntegration(
|
||||
tenant: Tenant
|
||||
): Required<GQLOIDCAuthIntegration> {
|
||||
// Grab the OIDC Integration.
|
||||
const integration = tenant.auth.integrations.oidc;
|
||||
export function getEnabledIntegration(
|
||||
integration: OIDCAuthIntegration
|
||||
): Required<OIDCAuthIntegration> {
|
||||
if (!integration.enabled) {
|
||||
// TODO: return a better error.
|
||||
throw new Error("integration not enabled");
|
||||
@@ -109,7 +110,7 @@ function getEnabledIntegration(
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) for some reason, type guards above to not allow coercion to this required type.
|
||||
return integration as Required<GQLOIDCAuthIntegration>;
|
||||
return integration as Required<OIDCAuthIntegration>;
|
||||
}
|
||||
|
||||
export const OIDCIDTokenSchema = Joi.object()
|
||||
@@ -135,9 +136,9 @@ export const OIDCIDTokenSchema = Joi.object()
|
||||
export async function findOrCreateOIDCUser(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
integration: GQLOIDCAuthIntegration,
|
||||
integration: OIDCAuthIntegration,
|
||||
token: OIDCIDToken
|
||||
) {
|
||||
): Promise<Readonly<User> | null> {
|
||||
// Unpack/validate the token content.
|
||||
const {
|
||||
sub,
|
||||
@@ -160,15 +161,11 @@ export async function findOrCreateOIDCUser(
|
||||
};
|
||||
|
||||
// Try to lookup user given their id provided in the `sub` claim.
|
||||
let user = await retrieveUserWithProfile(mongo, tenant.id, {
|
||||
// NOTE: (wyattjoh) as the current requirements do not allow multiple OIDC integrations, we are only getting the profile based on the OIDC provider.
|
||||
type: "oidc",
|
||||
id: sub,
|
||||
});
|
||||
let user = await retrieveUserWithProfile(mongo, tenant.id, profile);
|
||||
if (!user) {
|
||||
if (!integration.allowRegistration) {
|
||||
// Registration is disabled, so we can't create the user user here.
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// FIXME: implement rules.
|
||||
@@ -177,7 +174,7 @@ export async function findOrCreateOIDCUser(
|
||||
const username = preferred_username || nickname || name;
|
||||
|
||||
// Create the new user, as one didn't exist before!
|
||||
user = await upsert(mongo, tenant, {
|
||||
user = await insert(mongo, tenant, {
|
||||
username,
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
@@ -192,6 +189,47 @@ export async function findOrCreateOIDCUser(
|
||||
return user;
|
||||
}
|
||||
|
||||
export function findOrCreateOIDCUserWithToken(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
client: JwksClient,
|
||||
integration: OIDCAuthIntegration,
|
||||
token: string
|
||||
) {
|
||||
return new Promise<Readonly<User> | null>((resolve, reject) => {
|
||||
logger.trace({ tenantID: tenant.id }, "verifying oidc id_token");
|
||||
jwt.verify(
|
||||
token,
|
||||
signingKeyFactory(client),
|
||||
{
|
||||
issuer: integration.issuer,
|
||||
},
|
||||
async (err, decoded) => {
|
||||
logger.trace(
|
||||
{ tenantID: tenant.id },
|
||||
"finished verifying oidc id_token"
|
||||
);
|
||||
if (err) {
|
||||
// TODO: wrap error?
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await findOrCreateOIDCUser(
|
||||
mongo,
|
||||
tenant,
|
||||
integration,
|
||||
decoded as OIDCIDToken
|
||||
);
|
||||
return resolve(user);
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC_SCOPE is the set of scopes requested for users signing up via OIDC.
|
||||
*/
|
||||
@@ -218,7 +256,7 @@ export default class OIDCStrategy extends Strategy {
|
||||
private lookupJWKSClient(
|
||||
req: Request,
|
||||
tenantID: string,
|
||||
oidc: Required<GQLOIDCAuthIntegration>
|
||||
oidc: Required<OIDCAuthIntegration>
|
||||
): jwks.JwksClient {
|
||||
let tenantIntegration = this.cache.get(tenantID);
|
||||
if (!tenantIntegration) {
|
||||
@@ -249,7 +287,7 @@ export default class OIDCStrategy extends Strategy {
|
||||
return tenantIntegration.jwksClient;
|
||||
}
|
||||
|
||||
private userAuthenticatedCallback = (
|
||||
private userAuthenticatedCallback = async (
|
||||
req: Request,
|
||||
accessToken: string, // ignore the access token, we don't use it.
|
||||
refreshToken: string, // ignore the refresh token, we don't use it.
|
||||
@@ -274,9 +312,9 @@ export default class OIDCStrategy extends Strategy {
|
||||
|
||||
// Get the integration from the tenant. If needed, it will be used to create
|
||||
// a new strategy.
|
||||
let integration: Required<GQLOIDCAuthIntegration>;
|
||||
let integration: Required<OIDCAuthIntegration>;
|
||||
try {
|
||||
integration = getEnabledIntegration(tenant);
|
||||
integration = getEnabledIntegration(tenant.auth.integrations.oidc);
|
||||
} catch (err) {
|
||||
// TODO: wrap error?
|
||||
return done(err);
|
||||
@@ -286,36 +324,23 @@ export default class OIDCStrategy extends Strategy {
|
||||
const client = this.lookupJWKSClient(req, tenant.id, integration);
|
||||
|
||||
// Verify that the id_token is valid or not.
|
||||
jwt.verify(
|
||||
id_token,
|
||||
signingKeyFactory(client),
|
||||
{
|
||||
issuer: integration.issuer,
|
||||
},
|
||||
async (err, decoded) => {
|
||||
if (err) {
|
||||
// TODO: wrap error?
|
||||
return done(err);
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await findOrCreateOIDCUser(
|
||||
this.mongo,
|
||||
tenant,
|
||||
integration,
|
||||
decoded as OIDCIDToken
|
||||
);
|
||||
return done(null, user);
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
try {
|
||||
const user = await findOrCreateOIDCUserWithToken(
|
||||
this.mongo,
|
||||
tenant,
|
||||
client,
|
||||
integration,
|
||||
id_token
|
||||
);
|
||||
return done(null, user || undefined);
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
};
|
||||
|
||||
private createStrategy(
|
||||
req: Request,
|
||||
integration: Required<GQLOIDCAuthIntegration>
|
||||
integration: Required<OIDCAuthIntegration>
|
||||
): OAuth2Strategy {
|
||||
const { clientID, clientSecret, authorizationURL, tokenURL } = integration;
|
||||
|
||||
@@ -346,7 +371,7 @@ export default class OIDCStrategy extends Strategy {
|
||||
|
||||
// Get the integration from the tenant. If needed, it will be used to create
|
||||
// a new strategy.
|
||||
const integration = getEnabledIntegration(tenant);
|
||||
const integration = getEnabledIntegration(tenant.auth.integrations.oidc);
|
||||
|
||||
// Try to get the Tenant's cached integrations.
|
||||
let tenantIntegration = this.cache.get(tenant.id);
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Tenant } from "talk-server/models/tenant";
|
||||
import { retrieveUser } from "talk-server/models/user";
|
||||
import { checkJWTRevoked, JWTSigningConfig } from "talk-server/services/jwt";
|
||||
|
||||
import { Verifier } from "../jwt";
|
||||
|
||||
export interface JWTToken {
|
||||
/**
|
||||
* jti is the Token identifier. With normal login tokens, this is a randomly
|
||||
@@ -72,7 +74,7 @@ export interface JWTVerifierOptions {
|
||||
redis: Redis;
|
||||
}
|
||||
|
||||
export class JWTVerifier {
|
||||
export class JWTVerifier implements Verifier<JWTToken> {
|
||||
private signingConfig: JWTSigningConfig;
|
||||
private mongo: Db;
|
||||
private redis: Redis;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import jwks, { JwksClient } from "jwks-rsa";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { AppOptions } from "talk-server/app";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
|
||||
|
||||
import logger from "talk-server/logger";
|
||||
import { Verifier } from "../jwt";
|
||||
import {
|
||||
findOrCreateOIDCUserWithToken,
|
||||
getEnabledIntegration,
|
||||
isOIDCToken,
|
||||
OIDCIDToken,
|
||||
} from "../oidc";
|
||||
|
||||
export type OIDCIDToken = OIDCIDToken;
|
||||
|
||||
export type OIDCVerifierOptions = Pick<
|
||||
AppOptions,
|
||||
"mongo" | "redis" | "tenantCache"
|
||||
>;
|
||||
|
||||
export class OIDCVerifier implements Verifier<OIDCIDToken> {
|
||||
private mongo: Db;
|
||||
private cache: TenantCacheAdapter<JwksClient>;
|
||||
|
||||
constructor({ mongo, tenantCache }: OIDCVerifierOptions) {
|
||||
this.mongo = mongo;
|
||||
this.cache = new TenantCacheAdapter(tenantCache);
|
||||
}
|
||||
|
||||
public async verify(tokenString: string, token: OIDCIDToken, tenant: Tenant) {
|
||||
// Ensure that the integration is enabled.
|
||||
const integration = getEnabledIntegration(tenant.auth.integrations.oidc);
|
||||
|
||||
// Grab the JWKS client to verify the SSO ID token.
|
||||
let client = this.cache.get(tenant.id);
|
||||
if (!client) {
|
||||
logger.trace({ tenantID: tenant.id }, "jwks client not cached");
|
||||
client = jwks({
|
||||
jwksUri: integration.jwksURI,
|
||||
});
|
||||
|
||||
this.cache.set(tenant.id, client);
|
||||
} else {
|
||||
logger.trace({ tenantID: tenant.id }, "jwks client cached");
|
||||
}
|
||||
|
||||
return findOrCreateOIDCUserWithToken(
|
||||
this.mongo,
|
||||
tenant,
|
||||
client,
|
||||
integration,
|
||||
tokenString
|
||||
);
|
||||
}
|
||||
|
||||
public supports(
|
||||
token: OIDCIDToken | object,
|
||||
tenant: Tenant
|
||||
): token is OIDCIDToken {
|
||||
return (
|
||||
tenant.auth.integrations.oidc.enabled &&
|
||||
Boolean(tenant.auth.integrations.oidc.jwksURI) &&
|
||||
isOIDCToken(token)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user";
|
||||
import { upsert } from "talk-server/services/users";
|
||||
import { insert } from "talk-server/services/users";
|
||||
|
||||
import { Verifier } from "../jwt";
|
||||
|
||||
export interface SSOStrategyOptions {
|
||||
mongo: Db;
|
||||
@@ -69,7 +71,7 @@ export async function findOrCreateSSOUser(
|
||||
// FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method.
|
||||
|
||||
// Create the new user, as one didn't exist before!
|
||||
user = await upsert(mongo, tenant, {
|
||||
user = await insert(mongo, tenant, {
|
||||
username,
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
@@ -109,7 +111,7 @@ export interface SSOVerifierOptions {
|
||||
mongo: Db;
|
||||
}
|
||||
|
||||
export class SSOVerifier {
|
||||
export class SSOVerifier implements Verifier<SSOToken> {
|
||||
private mongo: Db;
|
||||
|
||||
constructor({ mongo }: SSOVerifierOptions) {
|
||||
|
||||
Reference in New Issue
Block a user