Skip to content
This repository was archived by the owner on May 20, 2025. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ SERVER_URL=http://localhost:3000 # The URL of your server
GITHUB_CLIENT_ID= # GitHub OAuth client ID
GITHUB_CLIENT_SECRET= # GitHub OAuth client secret

# --- Keycloak OAuth ---
KEYCLOAK_CLIENT_ID= # Keycloak OAuth client ID
KEYCLOAK_CLIENT_SECRET= # Keycloak OAuth client secret
KEYCLOAK_REALM= # Keycloak OAuth realm
KEYCLOAK_AUTH_SERVER_URL= # Keycloak OAuth server url

# --- Microsoft OAuth ---
MICROSOFT_CLIENT_ID= # Microsoft OAuth client ID
MICROSOFT_CLIENT_SECRET= # Microsoft OAuth client secret
Expand Down
8 changes: 8 additions & 0 deletions api/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
services:
redis:
image: redis:latest
command: ["redis-server", "--requirepass", "${REDIS_KEY}"]
ports:
- "6379:6379"
environment:
- REDIS_KEY=${REDIS_KEY}
8 changes: 4 additions & 4 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions api/script/default-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,13 @@ export function start(done: (err?: any, server?: express.Express, storage?: Stor
app.set("etag", false);
app.set("views", __dirname + "/views");
app.set("view engine", "ejs");
app.set('trust proxy', 1);
app.use("/auth/images/", express.static(__dirname + "/views/images"));
app.use(api.headers({ origin: process.env.CORS_ORIGIN || "http://localhost:4000" }));
app.use(api.health({ storage: storage, redisManager: redisManager }));

app.get('/ip', (request, response) => response.send(request.ip))

if (process.env.DISABLE_ACQUISITION !== "true") {
app.use(api.acquisition({ storage: storage, redisManager: redisManager }));
}
Expand Down
4 changes: 0 additions & 4 deletions api/script/redis-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,6 @@ export class RedisManager {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
auth_pass: process.env.REDIS_KEY,
tls: {
// Note: Node defaults CA's to those trusted by Mozilla
rejectUnauthorized: true,
},
};
this._opsClient = redis.createClient(redisConfig);
this._metricsClient = redis.createClient(redisConfig);
Expand Down
4 changes: 2 additions & 2 deletions api/script/routes/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ export function getHeadersMiddleware(config: HeadersConfig): express.RequestHand
}

res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, PATCH");
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, X-CodePush-Plugin-Name, X-CodePush-Plugin-Version, X-CodePush-SDK-Version"
"Content-Type, Authorization,X-CodePush-Plugin-Name, X-CodePush-Plugin-Version, X-CodePush-SDK-Version"
);
res.setHeader("Access-Control-Expose-Headers", "Location");

Expand Down
181 changes: 173 additions & 8 deletions api/script/routes/passport-authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@ import * as passportBearer from "passport-http-bearer";
import * as passportGitHub from "passport-github2";
import * as passportWindowsLive from "passport-windowslive";
import * as q from "q";
import * as superagent from "superagent"
import * as superagent from "superagent";
import rateLimit from "express-rate-limit";
import * as jwt from "jsonwebtoken";

import * as converterUtils from "../utils/converter";
import * as restErrorUtils from "../utils/rest-error-handling";
import * as restHeaders from "../utils/rest-headers";
import * as security from "../utils/security";
import * as storage from "../storage/storage";
import * as validationUtils from "../utils/validation";
import { Strategy } from "passport-oauth2";

import Promise = q.Promise;

Expand All @@ -38,10 +40,26 @@ interface EmailAccount {
primary?: boolean;
}

interface KeycloakConfig {
clientId: string;
clientSecret: string;
realm: string;
authServerUrl: string;
}
interface KeycloakUser {
sub: string;
name?: string;
email?: string;
preferred_username?: string;
given_name?: string;
family_name?: string;
}

export class PassportAuthentication {
private static AZURE_AD_PROVIDER_NAME = "azure-ad";
private static GITHUB_PROVIDER_NAME = "github";
private static MICROSOFT_PROVIDER_NAME = "microsoft";
private static KEYCLOAK_PROVIDER_NAME = "keycloak";

private _cookieSessionMiddleware: RequestHandler;
private _serverUrl: string;
Expand Down Expand Up @@ -165,25 +183,61 @@ export class PassportAuthentication {
this.setupAzureAdRoutes(router, microsoftClientId, microsoftClientSecret);
}

// KEYCLOAK_CLIENT_ID: The client ID you received from Keycloak when registering a client.
// KEYCLOAK_CLIENT_SECRET: The client secret you received from Keycloak when registering a client.
// KEYCLOAK_REALM: The realm you created in Keycloak when registering a client.
// KEYCLOAK_AUTH_SERVER_URL: The URL of the Keycloak server.
const keycloakClientId: string = process.env["KEYCLOAK_CLIENT_ID"];
const keycloakClientSecret: string = process.env["KEYCLOAK_CLIENT_SECRET"];
const keycloakAuthServerUrl: string = process.env["KEYCLOAK_AUTH_SERVER_URL"];
const keycloakRealm: string = process.env["KEYCLOAK_REALM"];
const isKeycloakAuthenticationEnabled: boolean =
!!this._serverUrl && !!keycloakClientId && !!keycloakClientSecret && !!keycloakAuthServerUrl;
if (isKeycloakAuthenticationEnabled) {
this.setupKeycloakRoutes(router, {
authServerUrl: keycloakAuthServerUrl,
clientId: keycloakClientId,
clientSecret: keycloakClientSecret,
realm: keycloakRealm,
});
}

router.get("/auth/login", this._cookieSessionMiddleware, (req: Request, res: Response): any => {
req.session["hostname"] = req.query.hostname;
res.render("authenticate", { action: "login", isGitHubAuthenticationEnabled, isMicrosoftAuthenticationEnabled });
res.render("authenticate", {
action: "login",
isGitHubAuthenticationEnabled,
isMicrosoftAuthenticationEnabled,
isKeycloakAuthenticationEnabled,
});
});

router.get("/auth/link", this._cookieSessionMiddleware, (req: Request, res: Response): any => {
req.session["authorization"] = req.query.access_token;
res.render("authenticate", { action: "link", isGitHubAuthenticationEnabled, isMicrosoftAuthenticationEnabled });
res.render("authenticate", {
action: "link",
isGitHubAuthenticationEnabled,
isMicrosoftAuthenticationEnabled,
isKeycloakAuthenticationEnabled,
});
});

router.get("/auth/register", this._cookieSessionMiddleware, (req: Request, res: Response): any => {
req.session["hostname"] = req.query.hostname;
res.render("authenticate", { action: "register", isGitHubAuthenticationEnabled, isMicrosoftAuthenticationEnabled });
res.render("authenticate", {
action: "register",
isGitHubAuthenticationEnabled,
isMicrosoftAuthenticationEnabled,
isKeycloakAuthenticationEnabled,
});
});

return router;
}

private static getEmailAddress(user: passport.Profile): string {
if (user.email) return user.email;

const emailAccounts: EmailAccount[] = user.emails;

if (!emailAccounts || emailAccounts.length === 0) {
Expand Down Expand Up @@ -226,6 +280,8 @@ export class PassportAuthentication {
return account.gitHubId;
case PassportAuthentication.MICROSOFT_PROVIDER_NAME:
return account.microsoftId;
case PassportAuthentication.KEYCLOAK_PROVIDER_NAME:
return account.keycloakId;
default:
throw new Error("Unrecognized provider");
}
Expand All @@ -242,6 +298,9 @@ export class PassportAuthentication {
case PassportAuthentication.MICROSOFT_PROVIDER_NAME:
account.microsoftId = id;
return;
case PassportAuthentication.KEYCLOAK_PROVIDER_NAME:
account.keycloakId = id;
return;
default:
throw new Error("Unrecognized provider");
}
Expand All @@ -260,7 +319,7 @@ export class PassportAuthentication {
);

router.get(
"/auth/register/" + providerName,
"/auth/register/" + providerName,
limiter,
this._cookieSessionMiddleware,
(req: Request, res: Response, next: (err?: any) => void): any => {
Expand Down Expand Up @@ -350,8 +409,8 @@ export class PassportAuthentication {
const message: string = isProviderValid
? "You are already registered with the service using this authentication provider.<br/>Please cancel the registration process (Ctrl-C) on the CLI and login with your account."
: "You are already registered with the service using a different authentication provider." +
"<br/>Please cancel the registration process (Ctrl-C) on the CLI and login with your registered account." +
"<br/>Once logged in, you can optionally link this provider to your account.";
"<br/>Please cancel the registration process (Ctrl-C) on the CLI and login with your registered account." +
"<br/>Once logged in, you can optionally link this provider to your account.";
restErrorUtils.sendAlreadyExistsPage(res, message);
return;
case "link":
Expand Down Expand Up @@ -393,7 +452,7 @@ export class PassportAuthentication {
restErrorUtils.sendForbiddenPage(
res,
"We weren't able to link your account, because the primary email address registered with your provider does not match the one on your CodePush account." +
"<br/>Please use a matching email address, or contact us if you'd like to change the email address on your CodePush account."
"<br/>Please use a matching email address, or contact us if you'd like to change the email address on your CodePush account."
);
return;
case "register":
Expand Down Expand Up @@ -481,6 +540,112 @@ export class PassportAuthentication {
this.setupCommonRoutes(router, providerName, strategyName);
}

private setupKeycloakRoutes(router: Router, keycloakConfig: KeycloakConfig): void {
const providerName = PassportAuthentication.KEYCLOAK_PROVIDER_NAME;
const strategyName = "keycloak";
const keycloakAuthUrl = `${keycloakConfig.authServerUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect`;
const options = {
authorizationURL: `${keycloakAuthUrl}/auth`,
tokenURL: `${keycloakAuthUrl}/token`,
clientID: keycloakConfig.clientId,
clientSecret: keycloakConfig.clientSecret,
callbackURL: this.getCallbackUrl(providerName),
scope: ["openid", "profile", "email"],
};
const verify = async (
accessToken: string,
_refreshToken: string,
_params: any,
_profile: any,
done: (error: any, user?: KeycloakUser) => void
) => {
try {
const response = await fetch(`${keycloakAuthUrl}/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error(`Failed to find user: ${response.statusText}`);
}
const user: KeycloakUser = await response.json();
return done(null, user);
} catch (error) {
return done(error);
}
};

passport.use(strategyName, new Strategy(options, verify));

router.post("/login-kc", async (req, res) => {
const { email, password } = req.body;

if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}

try {
const formData = new URLSearchParams();
formData.append("client_id", keycloakConfig.clientId);
formData.append("client_secret", keycloakConfig.clientSecret);
formData.append("grant_type", "password");
formData.append("username", email);
formData.append("password", password);
formData.append("scope", "openid profile email");

const response = await fetch(options.tokenURL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData.toString(),
});

if (!response.ok) {
const errorData = await response.json();
return res.status(response.status).json({ error: errorData.error_description || "Invalid credentials" });
}

const tokenData = await response.json();
const accessToken = tokenData.access_token;

const decodedToken = jwt.decode(accessToken) as { name?: string; email?: string };

if (!decodedToken?.email) {
return res.status(400).json({ error: "Email not found in token" });
}

const user = await this._storageInstance.getAccountByEmail(decodedToken.email);

if (!user) {
return res.status(403).json({ error: "User not registered. Please sign up first." });
}

const now = Date.now();
const friendlyName = `Login-${now}`;
const accessKey = {
name: security.generateSecureKey(user.id),
createdTime: now,
createdBy: req.ip,
description: friendlyName,
expires: now + DEFAULT_SESSION_EXPIRY,
friendlyName,
isSession: true,
};

await this._storageInstance.addAccessKey(user.id, accessKey);
const sessionKey = accessKey.name;

return res.json({
accessKey: sessionKey,
name: decodedToken.name,
email: decodedToken.email,
});
} catch (error) {
console.error("Login error:", error);
return res.status(500).json({ error: "Internal server error" });
}
});

this.setupCommonRoutes(router, providerName, strategyName);
}

private setupAzureAdRoutes(router: Router, microsoftClientId: string, microsoftClientSecret: string): void {
const providerName = PassportAuthentication.AZURE_AD_PROVIDER_NAME;
const strategyName = "azuread-openidconnect";
Expand Down
1 change: 1 addition & 0 deletions api/script/storage/azure-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export class AzureStorage implements storage.Storage {
azureAdId: updateProperties.azureAdId,
gitHubId: updateProperties.gitHubId,
microsoftId: updateProperties.microsoftId,
keycloakId: updateProperties.keycloakId,
};

return this._setupPromise
Expand Down
2 changes: 2 additions & 0 deletions api/script/storage/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface Account {
gitHubId?: string;
/*generated*/ id?: string;
microsoftId?: string;
keycloakId?: string;
/*const*/ name: string;
}

Expand All @@ -61,6 +62,7 @@ export interface App {
/*generated*/ createdTime: number;
/*generated*/ id?: string;
name: string;
iconUrl?: string;
}

export interface Deployment {
Expand Down
2 changes: 2 additions & 0 deletions api/script/types/rest-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,13 @@ export interface App {
/*generated*/ collaborators?: CollaboratorMap;
/*key*/ name: string;
/*generated*/ deployments?: string[];
iconUrl?: string;
}

/*in*/
export interface AppCreationRequest extends App {
manuallyProvisionDeployments?: boolean;
iconUrl?: string;
}

/*inout*/
Expand Down
Loading