Skip to content

Commit b5d24d2

Browse files
authored
AuthLevel decorator (#3439)
2 parents 15812b1 + ab0732e commit b5d24d2

12 files changed

+67
-57
lines changed

src/components/authentication/anonymous.decorator.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { createMetadataDecorator } from '@seedcompany/nest';
2+
3+
export const AuthLevel = createMetadataDecorator({
4+
setter: (level: 'authenticated' | 'anonymous' | 'sessionless') => level,
5+
types: ['class', 'method'],
6+
});
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export * from './authentication.service';
22
export { SessionHost } from './session.host';
3-
export * from './anonymous.decorator';
3+
export * from './auth-level.decorator';

src/components/authentication/login.resolver.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import { Privileges } from '../authorization';
1111
import { Power } from '../authorization/dto';
1212
import { UserLoader } from '../user';
1313
import { User } from '../user/dto';
14-
import { Anonymous } from './anonymous.decorator';
14+
import { AuthLevel } from './auth-level.decorator';
1515
import { AuthenticationService } from './authentication.service';
1616
import { LoginInput, LoginOutput, LogoutOutput } from './dto';
1717
import { SessionHost } from './session.host';
1818

1919
@Resolver(LoginOutput)
20+
@AuthLevel('anonymous')
2021
export class LoginResolver {
2122
constructor(
2223
private readonly authentication: AuthenticationService,
@@ -30,7 +31,6 @@ export class LoginResolver {
3031
@sensitive-secrets
3132
`,
3233
})
33-
@Anonymous()
3434
async login(@Args('input') input: LoginInput): Promise<LoginOutput> {
3535
const user = await this.authentication.login(input);
3636
await this.authentication.refreshCurrentSession();
@@ -43,7 +43,6 @@ export class LoginResolver {
4343
@sensitive-secrets
4444
`,
4545
})
46-
@Anonymous()
4746
async logout(): Promise<LogoutOutput> {
4847
const session = this.sessionHost.current;
4948
await this.authentication.logout(session.token);

src/components/authentication/password.resolver.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Args, Mutation, Resolver } from '@nestjs/graphql';
22
import { stripIndent } from 'common-tags';
3-
import { Anonymous } from './anonymous.decorator';
3+
import { AuthLevel } from './auth-level.decorator';
44
import { AuthenticationService } from './authentication.service';
55
import {
66
ChangePasswordArgs,
@@ -31,6 +31,7 @@ export class PasswordResolver {
3131
@Mutation(() => ForgotPasswordOutput, {
3232
description: 'Forgot password; send password reset email',
3333
})
34+
@AuthLevel('anonymous')
3435
async forgotPassword(
3536
@Args() { email }: ForgotPasswordArgs,
3637
): Promise<ForgotPasswordOutput> {
@@ -44,7 +45,7 @@ export class PasswordResolver {
4445
@sensitive-secrets
4546
`,
4647
})
47-
@Anonymous()
48+
@AuthLevel('anonymous')
4849
async resetPassword(
4950
@Args('input') input: ResetPasswordInput,
5051
): Promise<ResetPasswordOutput> {

src/components/authentication/register.resolver.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ import { Privileges } from '../authorization';
1111
import { Power } from '../authorization/dto';
1212
import { UserLoader } from '../user';
1313
import { User } from '../user/dto';
14-
import { Anonymous } from './anonymous.decorator';
14+
import { AuthLevel } from './auth-level.decorator';
1515
import { AuthenticationService } from './authentication.service';
1616
import { RegisterInput, RegisterOutput } from './dto';
1717

1818
@Resolver(RegisterOutput)
19+
@AuthLevel('anonymous')
1920
export class RegisterResolver {
2021
constructor(
2122
private readonly authentication: AuthenticationService,
@@ -28,7 +29,6 @@ export class RegisterResolver {
2829
@sensitive-secrets
2930
`,
3031
})
31-
@Anonymous()
3232
async register(@Args('input') input: RegisterInput): Promise<RegisterOutput> {
3333
const user = await this.authentication.register(input);
3434
await this.authentication.login(input);

src/components/authentication/session.interceptor.ts

Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { ConfigService } from '~/core';
2525
import { Identity } from '~/core/authentication';
2626
import { GlobalHttpHook, type IRequest } from '~/core/http';
2727
import { rolesForScope } from '../authorization/dto';
28-
import { Anonymous } from './anonymous.decorator';
28+
import { AuthLevel } from './auth-level.decorator';
2929
import { AuthenticationService } from './authentication.service';
3030
import { SessionHost } from './session.host';
3131

@@ -65,56 +65,59 @@ export class SessionInterceptor implements NestInterceptor {
6565
throw new Error('Session holder for request is not in async context');
6666
}
6767

68-
const type = executionContext.getType();
69-
70-
let isMutation = true;
71-
let session;
72-
if (type === 'graphql') {
73-
const gqlExecutionContext = GqlExecutionContext.create(executionContext);
74-
const op = gqlExecutionContext.getInfo().operation;
75-
isMutation = op.operation === 'mutation';
76-
session = await this.handleGql(executionContext);
77-
} else if (type === 'http') {
78-
const request = executionContext.switchToHttp().getRequest();
79-
isMutation = request.method !== 'GET' && request.method !== 'HEAD';
80-
session = await this.handleHttp(executionContext);
68+
const isMutation = this.isMutation(executionContext);
69+
const authLevel =
70+
AuthLevel.get(executionContext.getHandler() as FnLike) ??
71+
AuthLevel.get(executionContext.getClass()) ??
72+
(isMutation ? 'authenticated' : 'anonymous');
73+
74+
if (authLevel === 'sessionless') {
75+
return next.handle();
8176
}
82-
session$.next(session);
83-
84-
const allowAnonymous =
85-
Anonymous.get(executionContext.getHandler() as FnLike) ??
86-
Anonymous.get(executionContext.getClass()) ??
87-
!isMutation;
88-
if (!allowAnonymous && session) {
89-
this.identity.verifyLoggedIn();
77+
78+
const session = await this.startFromContext(executionContext);
79+
if (session) {
80+
session$.next(session);
81+
if (authLevel === 'authenticated') {
82+
this.identity.verifyLoggedIn();
83+
}
9084
}
9185

9286
return next.handle();
9387
}
9488

95-
private async handleHttp(executionContext: ExecutionContext) {
96-
const enabled = Reflect.getMetadata(
97-
'SESSION_WATERMARK',
98-
executionContext.getClass(),
99-
executionContext.getHandler().name,
100-
);
101-
if (!enabled) {
102-
return;
89+
private isMutation(executionContext: ExecutionContext) {
90+
switch (executionContext.getType()) {
91+
case 'graphql': {
92+
const gqlExecutionContext =
93+
GqlExecutionContext.create(executionContext);
94+
const op = gqlExecutionContext.getInfo().operation;
95+
return op.operation === 'mutation';
96+
}
97+
case 'http': {
98+
const request = executionContext.switchToHttp().getRequest();
99+
return request.method !== 'GET' && request.method !== 'HEAD';
100+
}
101+
default:
102+
return undefined;
103103
}
104-
const request = executionContext.switchToHttp().getRequest();
105-
return await this.hydrateSession({ request });
106104
}
107105

108-
private async handleGql(executionContext: ExecutionContext) {
109-
const gqlExecutionContext = GqlExecutionContext.create(executionContext);
110-
const ctx = gqlExecutionContext.getContext();
111-
const info = gqlExecutionContext.getInfo();
112-
113-
if (info.fieldName !== 'session') {
114-
const session = await this.hydrateSession(ctx);
115-
return session;
106+
private async startFromContext(executionContext: ExecutionContext) {
107+
switch (executionContext.getType()) {
108+
case 'graphql': {
109+
const gqlExecutionContext =
110+
GqlExecutionContext.create(executionContext);
111+
const ctx = gqlExecutionContext.getContext();
112+
return await this.hydrateSession(ctx);
113+
}
114+
case 'http': {
115+
const request = executionContext.switchToHttp().getRequest();
116+
return await this.hydrateSession({ request });
117+
}
118+
default:
119+
return undefined;
116120
}
117-
return undefined;
118121
}
119122

120123
async hydrateSession(context: Pick<GqlContextType, 'request'>) {

src/components/authentication/session.resolver.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ import { Privileges } from '../authorization';
1818
import { Power } from '../authorization/dto';
1919
import { UserLoader, UserService } from '../user';
2020
import { User } from '../user/dto';
21+
import { AuthLevel } from './auth-level.decorator';
2122
import { AuthenticationService } from './authentication.service';
2223
import { SessionOutput } from './dto';
2324
import { SessionHost } from './session.host';
2425
import { SessionInterceptor } from './session.interceptor';
2526

2627
@Resolver(SessionOutput)
28+
@AuthLevel('sessionless')
2729
export class SessionResolver {
2830
constructor(
2931
private readonly authentication: AuthenticationService,

src/components/file/file-url.controller.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import {
1010
Response,
1111
} from '@nestjs/common';
1212
import { type ID } from '~/common';
13-
import { Identity } from '~/core/authentication';
13+
import { AuthLevel, Identity } from '~/core/authentication';
1414
import { HttpAdapter, type IRequest, type IResponse } from '~/core/http';
1515
import { SessionInterceptor } from '../authentication/session.interceptor';
1616
import { FileService } from './file.service';
1717

1818
@Controller(FileUrlController.path)
19+
@AuthLevel('sessionless')
1920
export class FileUrlController {
2021
static path = '/file';
2122

src/components/file/local-bucket.controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { DateTime } from 'luxon';
1212
import { URL } from 'node:url';
1313
import { InputException } from '~/common';
14+
import { AuthLevel } from '~/core/authentication';
1415
import {
1516
HttpAdapter,
1617
type IRequest,
@@ -23,6 +24,7 @@ import { FileBucket, InvalidSignedUrlException } from './bucket';
2324
* This fakes S3 web hosting for use with LocalBuckets.
2425
*/
2526
@Controller(LocalBucketController.path)
27+
@AuthLevel('sessionless')
2628
export class LocalBucketController {
2729
static path = '/local-bucket';
2830

0 commit comments

Comments
 (0)