Browse Source

Feature/add OIDC authentication support

pull/5981/head
Germán Martín 3 weeks ago
parent
commit
dfdc0592ae
  1. 28
      apps/api/src/app/auth/auth.controller.ts
  2. 45
      apps/api/src/app/auth/auth.module.ts
  3. 40
      apps/api/src/app/auth/oidc.strategy.ts
  4. 4
      apps/api/src/app/info/info.service.ts
  5. 9
      apps/api/src/main.ts
  6. 7
      apps/api/src/services/configuration/configuration.service.ts
  7. 7
      apps/api/src/services/interfaces/environment.interface.ts
  8. 7
      apps/client/src/app/components/header/header.component.ts
  9. 1
      apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts
  10. 11
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html
  11. 2
      libs/common/src/lib/permissions.ts
  12. 92
      package-lock.json
  13. 3
      package.json
  14. 1
      prisma/schema.prisma

28
apps/api/src/app/auth/auth.controller.ts

@ -102,6 +102,34 @@ export class AuthController {
}
}
@Get('oidc')
@UseGuards(AuthGuard('oidc'))
public oidcLogin() {
// Initiates the OIDC login flow
}
@Get('oidc/callback')
@UseGuards(AuthGuard('oidc'))
@Version(VERSION_NEUTRAL)
public oidcLoginCallback(@Req() request: Request, @Res() response: Response) {
// Handles the OIDC callback
const jwt: string = (request.user as any).jwt;
if (jwt) {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
);
} else {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
}
}
@Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateRegistrationOptions() {

45
apps/api/src/app/auth/auth.module.ts

@ -4,10 +4,11 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { Module, Logger } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ApiKeyStrategy } from './api-key.strategy';
@ -15,6 +16,7 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy';
import { OidcStrategy } from './oidc.strategy';
@Module({
controllers: [AuthController],
@ -36,6 +38,47 @@ import { JwtStrategy } from './jwt.strategy';
AuthService,
GoogleStrategy,
JwtStrategy,
{
provide: OidcStrategy,
useFactory: async (
authService: AuthService,
configurationService: ConfigurationService
) => {
const issuer = configurationService.get('OIDC_ISSUER');
const options: any = {
callbackURL: `${configurationService.get(
'ROOT_URL'
)}/api/auth/oidc/callback`,
clientID: configurationService.get('OIDC_CLIENT_ID'),
clientSecret: configurationService.get('OIDC_CLIENT_SECRET')
};
if (issuer) {
try {
const response = await fetch(
`${issuer}/.well-known/openid-configuration`
);
const config = await response.json();
options.authorizationURL = config.authorization_endpoint;
options.issuer = issuer;
options.tokenURL = config.token_endpoint;
options.userInfoURL = config.userinfo_endpoint;
} catch (error) {
Logger.error(error, 'OidcStrategy');
}
} else {
options.authorizationURL = configurationService.get(
'OIDC_AUTHORIZATION_URL'
);
options.tokenURL = configurationService.get('OIDC_TOKEN_URL');
options.userInfoURL = configurationService.get('OIDC_USER_INFO_URL');
}
return new OidcStrategy(authService, options);
},
inject: [AuthService, ConfigurationService]
},
WebAuthService
]
})

40
apps/api/src/app/auth/oidc.strategy.ts

@ -0,0 +1,40 @@
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { DoneCallback } from 'passport';
import { Strategy } from 'passport-openidconnect';
import { AuthService } from './auth.service';
@Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
public constructor(
private readonly authService: AuthService,
options: any
) {
super({
...options,
passReqToCallback: true,
scope: ['openid', 'profile', 'email']
});
}
public async validate(
_request: any,
_issuer: string,
profile: any,
done: DoneCallback
) {
try {
const jwt = await this.authService.validateOAuthLogin({
provider: Provider.OIDC,
thirdPartyId: profile.id
});
done(null, { jwt });
} catch (error) {
Logger.error(error, 'OidcStrategy');
done(error, false);
}
}
}

4
apps/api/src/app/info/info.service.ts

@ -55,6 +55,10 @@ export class InfoService {
globalPermissions.push(permissions.enableAuthGoogle);
}
if (this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) {
globalPermissions.push(permissions.enableAuthOidc);
}
if (this.configurationService.get('ENABLE_FEATURE_AUTH_TOKEN')) {
globalPermissions.push(permissions.enableAuthToken);
}

9
apps/api/src/main.ts

@ -15,6 +15,7 @@ import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { NextFunction, Request, Response } from 'express';
import * as session from 'express-session';
import helmet from 'helmet';
import { AppModule } from './app/app.module';
@ -61,6 +62,14 @@ async function bootstrap() {
})
);
app.use(
session({
resave: false,
saveUninitialized: false,
secret: configService.get<string>('JWT_SECRET_KEY')
})
);
// Support 10mb csv/json files for importing activities
app.useBodyParser('json', { limit: '10mb' });

7
apps/api/src/services/configuration/configuration.service.ts

@ -41,6 +41,7 @@ export class ConfigurationService {
default: []
}),
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
@ -57,6 +58,12 @@ export class ConfigurationService {
JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }),
OIDC_AUTHORIZATION_URL: str({ default: '' }),
OIDC_CLIENT_ID: str({ default: '' }),
OIDC_CLIENT_SECRET: str({ default: '' }),
OIDC_ISSUER: str({ default: '' }),
OIDC_TOKEN_URL: str({ default: '' }),
OIDC_USER_INFO_URL: str({ default: '' }),
PORT: port({ default: DEFAULT_PORT }),
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY

7
apps/api/src/services/interfaces/environment.interface.ts

@ -17,6 +17,7 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCES: string[];
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[];
ENABLE_FEATURE_AUTH_GOOGLE: boolean;
ENABLE_FEATURE_AUTH_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
@ -32,6 +33,12 @@ export interface Environment extends CleanedEnvAccessors {
JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_CHART_ITEMS: number;
OIDC_AUTHORIZATION_URL: string;
OIDC_CLIENT_ID: string;
OIDC_CLIENT_SECRET: string;
OIDC_ISSUER: string;
OIDC_TOKEN_URL: string;
OIDC_USER_INFO_URL: string;
PORT: number;
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number;
PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number;

7
apps/client/src/app/components/header/header.component.ts

@ -105,6 +105,7 @@ export class GfHeaderComponent implements OnChanges {
public hasFilters: boolean;
public hasImpersonationId: boolean;
public hasPermissionForAuthGoogle: boolean;
public hasPermissionForAuthOidc: boolean;
public hasPermissionForAuthToken: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
@ -170,6 +171,11 @@ export class GfHeaderComponent implements OnChanges {
permissions.enableAuthGoogle
);
this.hasPermissionForAuthOidc = hasPermission(
this.info?.globalPermissions,
permissions.enableAuthOidc
);
this.hasPermissionForAuthToken = hasPermission(
this.info?.globalPermissions,
permissions.enableAuthToken
@ -286,6 +292,7 @@ export class GfHeaderComponent implements OnChanges {
data: {
accessToken: '',
hasPermissionToUseAuthGoogle: this.hasPermissionForAuthGoogle,
hasPermissionToUseAuthOidc: this.hasPermissionForAuthOidc,
hasPermissionToUseAuthToken: this.hasPermissionForAuthToken,
title: $localize`Sign in`
},

1
apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts

@ -1,6 +1,7 @@
export interface LoginWithAccessTokenDialogParams {
accessToken: string;
hasPermissionToUseAuthGoogle: boolean;
hasPermissionToUseAuthOidc: boolean;
hasPermissionToUseAuthToken: boolean;
title: string;
}

11
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html

@ -45,6 +45,17 @@
>
</div>
}
@if (data.hasPermissionToUseAuthOidc) {
<div class="d-flex flex-column mt-2">
<a
class="px-4 rounded-pill"
href="../api/v1/auth/oidc"
mat-stroked-button
><span i18n>Sign in with OIDC</span></a
>
</div>
}
</form>
</div>
</div>

2
libs/common/src/lib/permissions.ts

@ -29,6 +29,7 @@ export const permissions = {
deleteUser: 'deleteUser',
deleteWatchlistItem: 'deleteWatchlistItem',
enableAuthGoogle: 'enableAuthGoogle',
enableAuthOidc: 'enableAuthOidc',
enableAuthToken: 'enableAuthToken',
enableDataProviderGhostfolio: 'enableDataProviderGhostfolio',
enableFearAndGreedIndex: 'enableFearAndGreedIndex',
@ -159,6 +160,7 @@ export function filterGlobalPermissions(
return globalPermissions.filter((permission) => {
return (
permission !== permissions.enableAuthGoogle &&
permission !== permissions.enableAuthOidc &&
permission !== permissions.enableSubscription
);
});

92
package-lock.json

@ -63,6 +63,7 @@
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"envalid": "8.1.0",
"express-session": "^1.18.2",
"fuse.js": "7.1.0",
"google-spreadsheet": "3.2.0",
"helmet": "7.0.0",
@ -83,6 +84,7 @@
"passport-google-oauth20": "2.0.0",
"passport-headerapikey": "1.2.2",
"passport-jwt": "4.0.1",
"passport-openidconnect": "^0.1.2",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.1",
"stripe": "18.5.0",
@ -125,6 +127,7 @@
"@storybook/angular": "9.1.5",
"@trivago/prettier-plugin-sort-imports": "5.2.2",
"@types/big.js": "6.2.2",
"@types/express-session": "^1.18.2",
"@types/google-spreadsheet": "3.1.5",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.20",
@ -14201,6 +14204,16 @@
"@types/send": "*"
}
},
"node_modules/@types/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/express/node_modules/@types/express-serve-static-core": {
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
@ -22414,6 +22427,46 @@
"express": ">= 4.11"
}
},
"node_modules/express-session": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
"integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/express-session/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/express/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@ -33742,7 +33795,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@ -34494,6 +34546,23 @@
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-openidconnect": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.2.tgz",
"integrity": "sha512-JX3rTyW+KFZ/E9OF/IpXJPbyLO9vGzcmXB5FgSP2jfL3LGKJPdV7zUE8rWeKeeI/iueQggOeFa3onrCmhxXZTg==",
"license": "MIT",
"dependencies": {
"oauth": "0.10.x",
"passport-strategy": "1.x.x"
},
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
@ -35875,6 +35944,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -40233,6 +40311,18 @@
"node": ">=8"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",

3
package.json

@ -109,6 +109,7 @@
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"envalid": "8.1.0",
"express-session": "^1.18.2",
"fuse.js": "7.1.0",
"google-spreadsheet": "3.2.0",
"helmet": "7.0.0",
@ -129,6 +130,7 @@
"passport-google-oauth20": "2.0.0",
"passport-headerapikey": "1.2.2",
"passport-jwt": "4.0.1",
"passport-openidconnect": "^0.1.2",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.1",
"stripe": "18.5.0",
@ -171,6 +173,7 @@
"@storybook/angular": "9.1.5",
"@trivago/prettier-plugin-sort-imports": "5.2.2",
"@types/big.js": "6.2.2",
"@types/express-session": "^1.18.2",
"@types/google-spreadsheet": "3.1.5",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.20",

1
prisma/schema.prisma

@ -335,6 +335,7 @@ enum Provider {
ANONYMOUS
GOOGLE
INTERNET_IDENTITY
OIDC
}
enum Role {

Loading…
Cancel
Save