Skip to content

Commit 034f6c7

Browse files
authored
API Key Authentication (#188)
1 parent 72a6617 commit 034f6c7

6 files changed

Lines changed: 148 additions & 24 deletions

File tree

packages/open-collaboration-server/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Usage of the public instance is bound to its [Terms of Use](https://www.open-col
2323
| OCT_LOGIN_PAGE_URL | Url of the login page. Defaults to /login.html?token={token} |
2424
| OCT_LOGIN_SUCCESS_URL | Url of the login success page. Defaults a simple "Login Successful. You can close this page" text |
2525
| OCT_ACTIVATE_SIMPLE_LOGIN | Activates the simple login handler to alow unverified authentication just with username and optionally email |
26+
| OCT_ACTIVATE_API_KEY_AUTH | Activates API key authentication. When enabled, a key can be provided via `OCT_API_KEY` or one will be auto-generated and logged on startup |
27+
| OCT_API_KEY | Sets a specific API key for API key authentication. If not set and API key auth is activated, a key will be auto-generated |
2628
| OCT_REDIRECT_URL_WHITELIST | A comma seperated list to allow usage of the specified URLs with the `redirect` query parameter when authenticating with a provider which redirects back after success. The query of a URL is ignored when validating against this list |
2729
| OCT_BASE_URL | Base URL of the server is reachable under. Used for oauth redirects |
2830
| OCT_CORS_ALLOWED_ORIGINS | `,` seperated list to configure the allowed origins for CORS. This will be evaluated based on the origin header of the request. if there is no match, fail the request. if not set all origin will be allowed |
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// ******************************************************************************
2+
// Copyright 2024 TypeFox GmbH
3+
// This program and the accompanying materials are made available under the
4+
// terms of the MIT License, which is available in the project root.
5+
// ******************************************************************************
6+
import { inject, injectable, postConstruct } from 'inversify';
7+
import { type Express } from 'express';
8+
import { Emitter, FormAuthProvider, Info } from 'open-collaboration-protocol';
9+
import { AuthEndpoint, AuthSuccessEvent } from './auth-endpoint.js';
10+
import { Logger } from '../utils/logging.js';
11+
import { Configuration } from '../utils/configuration.js';
12+
import { generateSecureId } from '../utils/cryptography.js';
13+
14+
@injectable()
15+
export class ApiKeyAuthEndpoint implements AuthEndpoint {
16+
17+
protected static readonly ENDPOINT = '/api/login/apikey';
18+
19+
@inject(Logger) protected logger: Logger;
20+
21+
@inject(Configuration) protected configuration: Configuration;
22+
23+
private authSuccessEmitter = new Emitter<AuthSuccessEvent>();
24+
onDidAuthenticate = this.authSuccessEmitter.event;
25+
26+
private apiKey: string = '';
27+
28+
@postConstruct()
29+
protected initialize(): void {
30+
if (!this.shouldActivate()) {
31+
return;
32+
}
33+
const configuredKey = this.configuration.getValue('oct-api-key');
34+
if (configuredKey) {
35+
this.apiKey = configuredKey;
36+
this.logger.info('API key authentication enabled (key provided via configuration)');
37+
} else {
38+
this.apiKey = generateSecureId(48);
39+
this.logger.info(`API key authentication enabled (auto-generated key): ${this.apiKey}`);
40+
}
41+
}
42+
43+
shouldActivate(): boolean {
44+
return this.configuration.getValue('oct-activate-api-key-auth', 'boolean') ?? false;
45+
}
46+
47+
getProtocolProvider(): FormAuthProvider {
48+
return {
49+
type: 'form',
50+
name: 'apikey',
51+
endpoint: ApiKeyAuthEndpoint.ENDPOINT,
52+
label: {
53+
code: 'ApiKeyLoginLabel',
54+
message: 'API Key',
55+
params: []
56+
},
57+
details: {
58+
code: 'ApiKeyLoginDetails',
59+
message: 'Login with a server API key for non-interactive use',
60+
params: []
61+
},
62+
group: {
63+
code: Info.Codes.BuiltinsGroup,
64+
message: 'Builtins',
65+
params: []
66+
},
67+
fields: [
68+
{
69+
name: 'apiKey',
70+
label: {
71+
code: 'ApiKeyLabel',
72+
message: 'API Key',
73+
params: []
74+
},
75+
required: true,
76+
placeHolder: {
77+
code: 'ApiKeyPlaceholder',
78+
message: 'The server API key',
79+
params: []
80+
}
81+
}
82+
]
83+
};
84+
}
85+
86+
onStart(app: Express, _hostname: string, _port: number): void {
87+
app.post(ApiKeyAuthEndpoint.ENDPOINT, async (req, res) => {
88+
try {
89+
const token = req.body.token as string;
90+
const apiKey = req.body.apiKey as string;
91+
if (!token || !apiKey) {
92+
res.status(400);
93+
res.send('Missing token or apiKey');
94+
return;
95+
}
96+
if (apiKey !== this.apiKey) {
97+
res.status(403);
98+
res.send('Invalid API key');
99+
return;
100+
}
101+
await Promise.all(this.authSuccessEmitter.fire({
102+
token,
103+
userInfo: { name: 'API Key User', authProvider: 'API Key' }
104+
}));
105+
res.send('Ok');
106+
} catch (err) {
107+
this.logger.error('Failed to perform API key login', err);
108+
res.status(400);
109+
res.send('Failed to perform API key login');
110+
}
111+
});
112+
}
113+
}

packages/open-collaboration-server/src/credentials-manager.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { inject, injectable, postConstruct } from 'inversify';
88
import { User, isUser } from './types.js';
99
import { UserManager } from './user-manager.js';
1010
import * as jose from 'jose';
11-
import { nanoid, customAlphabet } from 'nanoid';
11+
import { generateSecureId } from './utils/cryptography.js';
1212
import { Disposable, Emitter, Event } from 'open-collaboration-protocol';
1313
import { Logger } from './utils/logging.js';
1414
import { UserInfo } from './auth-endpoints/auth-endpoint.js';
@@ -33,7 +33,6 @@ export class CredentialsManager {
3333
@inject(Configuration) protected configuration: Configuration;
3434

3535
protected deferredAuths = new Map<string, DelayedAuth>();
36-
protected nanoid = this.generateAlphabet();
3736

3837
@postConstruct()
3938
initialize() {
@@ -61,7 +60,7 @@ export class CredentialsManager {
6160
}
6261

6362
async startAuth(): Promise<string> {
64-
const confirmToken = this.secureId();
63+
const confirmToken = generateSecureId(24);
6564
const updateEmitter = new Emitter<string>();
6665
const failureEmitter = new Emitter<Error>();
6766
const dispose = () => {
@@ -131,22 +130,4 @@ export class CredentialsManager {
131130
protected async getJwtExpiration(): Promise<string | number | undefined> {
132131
return undefined;
133132
}
134-
135-
protected generateAlphabet(): typeof nanoid {
136-
let alphabet = '';
137-
for (let digit = 48 /* '0' */; digit <= 57 /* '9' */; digit++) {
138-
alphabet += String.fromCharCode(digit);
139-
}
140-
for (let letter = 65 /* 'A' */; letter <= 90 /* 'Z' */; letter++) {
141-
alphabet += String.fromCharCode(letter);
142-
}
143-
for (let letter = 97 /* 'a' */; letter <= 122 /* 'z' */; letter++) {
144-
alphabet += String.fromCharCode(letter);
145-
}
146-
return customAlphabet(alphabet, 24);
147-
}
148-
149-
secureId(): string {
150-
return this.nanoid(24);
151-
}
152133
}

packages/open-collaboration-server/src/inversify-module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { PeerManager } from './peer-manager.js';
2121
import { AuthentikOAuthEndpoint } from './auth-endpoints/authentik-endpoint.js';
2222
import { KeycloakOAuthEndpoint } from './auth-endpoints/keycloak-endpoint.js';
2323
import { GenericOAuthEndpoint } from './auth-endpoints/generic-oauth-endpoint.js';
24+
import { ApiKeyAuthEndpoint } from './auth-endpoints/api-key-endpoint.js';
2425

2526
/**
2627
* This is the default dependency injection container module for the Open Collaboration Server.
@@ -56,5 +57,7 @@ export default new ContainerModule(bind => {
5657
bind(AuthEndpoint).toService(KeycloakOAuthEndpoint);
5758
bind(GenericOAuthEndpoint).toSelf().inSingletonScope();
5859
bind(AuthEndpoint).toService(GenericOAuthEndpoint);
60+
bind(ApiKeyAuthEndpoint).toSelf().inSingletonScope();
61+
bind(AuthEndpoint).toService(ApiKeyAuthEndpoint);
5962

6063
});

packages/open-collaboration-server/src/room-manager.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { MessageRelay } from './message-relay.js';
1010
import { Peer, Room, User, isUser } from './types.js';
1111
import { Messages, BroadcastMessage, NotificationMessage, RequestMessage, ResponseMessage, isObject, Info, Event, Disposable, Emitter, JoinRoomResponse, JoinRoomPollResponse, JoinResponse } from 'open-collaboration-protocol';
1212
import { Logger } from './utils/logging.js';
13+
import { generateSecureId } from './utils/cryptography.js';
1314

1415
export interface PreparedRoom {
1516
id: string;
@@ -67,7 +68,7 @@ export class RoomManager {
6768
}
6869

6970
async prepareRoom(user: User): Promise<PreparedRoom> {
70-
const id = this.credentials.secureId();
71+
const id = generateSecureId(24);
7172
const claim: RoomClaim = {
7273
room: id,
7374
user: {
@@ -164,7 +165,7 @@ export class RoomManager {
164165

165166
async requestJoin(room: Room, user: User): Promise<string> {
166167
this.logger.info(`Request to join room [id: '${room.id}'] by user [id: '${user.id}' | name: '${user.name}' | email: '${user.email ?? '<none>'}']`);
167-
const responseId = this.credentials.secureId();
168+
const responseId = generateSecureId(24);
168169
const timeout = setTimeout(() => {
169170
pollResult.update({
170171
code: 'JoinTimeout',
@@ -189,7 +190,7 @@ export class RoomManager {
189190
};
190191
this.pollResults.set(responseId, pollResult);
191192
try {
192-
const requestMessage = RequestMessage.create(Messages.Peer.Join, this.credentials.secureId(), '', room.host.id, [user]);
193+
const requestMessage = RequestMessage.create(Messages.Peer.Join, generateSecureId(24), '', room.host.id, [user]);
193194
const responsePromise = this.messageRelay.sendRequest(
194195
room.host,
195196
requestMessage,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// ******************************************************************************
2+
// Copyright 2024 TypeFox GmbH
3+
// This program and the accompanying materials are made available under the
4+
// terms of the MIT License, which is available in the project root.
5+
// ******************************************************************************
6+
import { customAlphabet } from 'nanoid';
7+
8+
const ALPHANUMERIC_ALPHABET = (() => {
9+
let alphabet = '';
10+
for (let digit = 48 /* '0' */; digit <= 57 /* '9' */; digit++) {
11+
alphabet += String.fromCharCode(digit);
12+
}
13+
for (let letter = 65 /* 'A' */; letter <= 90 /* 'Z' */; letter++) {
14+
alphabet += String.fromCharCode(letter);
15+
}
16+
for (let letter = 97 /* 'a' */; letter <= 122 /* 'z' */; letter++) {
17+
alphabet += String.fromCharCode(letter);
18+
}
19+
return alphabet;
20+
})();
21+
22+
export function generateSecureId(length: number): string {
23+
return customAlphabet(ALPHANUMERIC_ALPHABET, length)();
24+
}

0 commit comments

Comments
 (0)