Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/db state #88

Merged
merged 11 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,17 @@ jobs:
# token: ${{ secrets.CODECOV_TOKEN }}

- name: Upload playwright results
uses: actions/upload-artifact@v2
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-results
path: dist/.playwright
retention-days: 30

- name: Upload testcontainer logs
if: always()
uses: actions/upload-artifact@v4
with:
name: testcontainer-logs
path: tmp/logs
retention-days: 30
21 changes: 13 additions & 8 deletions apps/holder-app-e2e/src/credentials.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,19 @@ async function getAxiosInstance(port: number) {

async function receiveCredential(pin = false) {
const axios = await getAxiosInstance(config.issuerPort);
const response = await axios.post(`/sessions`, {
credentialSubject: {
prename: 'Max',
surname: 'Mustermann',
},
credentialId: 'Identity',
pin,
});
const response = await axios
.post(`/sessions`, {
credentialSubject: {
prename: 'Max',
surname: 'Mustermann',
},
credentialId: 'Identity',
pin,
})
.catch((e) => {
console.log(e);
throw Error('Failed to create session');
});
const uri = response.data.uri;
const userPin = response.data.userPin;
await page.evaluate(`navigator.clipboard.writeText("${uri}")`);
Expand Down
4 changes: 1 addition & 3 deletions apps/holder-app/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@
*ngIf="loggedIn"
>
<div fxLayout="column" fxLayoutAlign=" center">
<a mat-fab routerLink="/scan" routerLinkActive="active-link"
><mat-icon>qr_code_scanner</mat-icon></a
>
<a mat-fab (click)="showScanner()"><mat-icon>qr_code_scanner</mat-icon></a>
</div>
<div fxLayout="column" fxLayoutAlign=" center">
<a mat-icon-button routerLink="/credentials" routerLinkActive="active-link"
Expand Down
16 changes: 14 additions & 2 deletions apps/holder-app/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import {
Router,
RouterLink,
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { FlexLayoutModule } from 'ng-flex-layout';
Expand Down Expand Up @@ -34,7 +39,8 @@ export class AppComponent implements OnInit {
constructor(
private checkForUpdatesService: CheckForUpdatesService,
private settingsService: SettingsService,
private authService: AuthService
private authService: AuthService,
private router: Router
) {}
async ngOnInit(): Promise<void> {
const loggedIn = await firstValueFrom(
Expand All @@ -47,4 +53,10 @@ export class AppComponent implements OnInit {
document.getElementById('content')?.removeAttribute('class');
}
}

showScanner() {
this.router
.navigateByUrl('/', { skipLocationChange: true })
.then(() => this.router.navigateByUrl('/scan'));
}
}
11 changes: 10 additions & 1 deletion apps/holder-backend/src/app/oid4vc/oid4vc.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,18 @@ import { Oid4vpService } from './oid4vp/oid4vp.service';
import { CredentialsModule } from '../credentials/credentials.module';
import { HistoryModule } from '../history/history.module';
import { AuthModule } from '../auth/auth.module';
import { VCISessionEntity } from './oid4vci/entities/vci-session.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { VPSessionEntity } from './oid4vp/entities/vp-session.entity';

@Module({
imports: [HttpModule, CredentialsModule, HistoryModule, AuthModule],
imports: [
HttpModule,
CredentialsModule,
HistoryModule,
AuthModule,
TypeOrmModule.forFeature([VCISessionEntity, VPSessionEntity]),
],
controllers: [Oid4vciController, Oid4vpController],
providers: [Oid4vciService, Oid4vpService],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { IsString } from 'class-validator';

export class Oid4vciParseRequest {
@IsString()
url: string;

@IsBoolean()
@IsOptional()
noSession?: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';

@Entity()
export class VCISessionEntity {
@PrimaryColumn({ type: 'uuid' })
id: string;

@Column()
user: string;

@Column()
state: string;

@CreateDateColumn()
createdAt: Date;
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ export class Oid4vciController {
@ApiOperation({ summary: 'parse a URL, returns the included information' })
@Post('parse')
@ApiCreatedResponse({ description: 'URL parsed', type: Oid4vciParseRepsonse })
parse(@Body() value: Oid4vciParseRequest): Promise<Oid4vciParseRepsonse> {
return this.oid4vciService.parse(value);
parse(
@Body() value: Oid4vciParseRequest,
@AuthenticatedUser() user: KeycloakUser
): Promise<Oid4vciParseRepsonse> {
return this.oid4vciService.parse(value, user.sub);
}

@ApiOperation({ summary: 'accept a credential' })
Expand Down
97 changes: 52 additions & 45 deletions apps/holder-backend/src/app/oid4vc/oid4vci/oid4vci.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import {
GrantTypes,
IssuerMetadataV1_0_13,
JwtVerifyResult,
type CredentialConfigurationSupported,
type CredentialSupportedSdJwtVc,
type Jwt,
type MetadataDisplay,
type ProofOfPossessionCallbacks,
} from '@sphereon/oid4vci-common';
import { DIDDocument } from 'did-resolver';
Expand All @@ -23,31 +21,27 @@ import { decodeJwt } from 'jose';
import { CredentialsService } from '../../credentials/credentials.service';
import { KeysService } from '../../keys/keys.service';
import { AcceptRequestDto } from './dto/accept-request.dto';

type Session = {
//instead of storing the client, we could also generate it on demand. In this case we need to store the uri
client: OpenID4VCIClient;
relyingParty: string;
credentials: CredentialConfigurationSupported[];
issuer: MetadataDisplay;
created: Date;
pinRequired: boolean;
};
import { InjectRepository } from '@nestjs/typeorm';
import { VCISessionEntity } from './entities/vci-session.entity';
import { Repository } from 'typeorm';

@Injectable()
export class Oid4vciService {
sessions: Map<string, Session> = new Map();

sdjwt: SDJwtVcInstance;
private sdjwt: SDJwtVcInstance;

constructor(
private credentialsService: CredentialsService,
@Inject('KeyService') private keysService: KeysService
@Inject('KeyService') private keysService: KeysService,
@InjectRepository(VCISessionEntity)
private sessionRepository: Repository<VCISessionEntity>
) {
this.sdjwt = new SDJwtVcInstance({ hasher: digest });
}

async parse(data: Oid4vciParseRequest): Promise<Oid4vciParseRepsonse> {
async parse(
data: Oid4vciParseRequest,
user: string
): Promise<Oid4vciParseRepsonse> {
if (data.url.startsWith('openid-credential-offer')) {
const client = await OpenID4VCIClient.fromURI({
uri: data.url,
Expand All @@ -56,26 +50,15 @@ export class Oid4vciService {
// get the credential offer
const metadata =
(await client.retrieveServerMetadata()) as EndpointMetadataResultV1_0_13;
const supportedCredentials = (
metadata.credentialIssuerMetadata as IssuerMetadataV1_0_13
).credential_configurations_supported;
const credentials = (
client.credentialOffer.credential_offer as CredentialOfferPayloadV1_0_13
).credential_configuration_ids.map(
(credential) => supportedCredentials[credential]
);
const credentials = this.getCredentials(client, metadata);
const id = uuid();
if (!data.noSession) {
this.sessions.set(id, {
client,
relyingParty: client.getIssuer(),
credentials,
//allows use to remove the session after a certain time
created: new Date(),
issuer: metadata.credentialIssuerMetadata.display[0],
pinRequired: client.credentialOffer.userPinRequired,
});
}
await this.sessionRepository.save(
this.sessionRepository.create({
id,
state: await client.exportState(),
user,
})
);
return {
sessionId: id,
credentials,
Expand All @@ -88,11 +71,29 @@ export class Oid4vciService {
}
}

private getCredentials(
client: OpenID4VCIClient,
metadata: EndpointMetadataResultV1_0_13
) {
const supportedCredentials = (
metadata.credentialIssuerMetadata as IssuerMetadataV1_0_13
).credential_configurations_supported;
return (
client.credentialOffer.credential_offer as CredentialOfferPayloadV1_0_13
).credential_configuration_ids.map(
(credential) => supportedCredentials[credential]
);
}

async accept(accept: AcceptRequestDto, user: string) {
const data = this.sessions.get(accept.id);
if (!data) {
throw new Error('Session not found');
const session = await this.sessionRepository.findOneBy({
id: accept.id,
});

if (!session || session.user !== user) {
throw new ConflictException('Invalid session');
}
const client = await OpenID4VCIClient.fromState({ state: session.state });

//use the first key, can be changed to use a specific or unique key
const key = await this.keysService.firstOrCreate(user);
Expand All @@ -113,13 +114,19 @@ export class Oid4vciService {
}),
};

if (data.pinRequired && !accept.txCode) {
if (client.credentialOffer.userPinRequired && !accept.txCode) {
throw new ConflictException('PIN required');
}

await data.client.acquireAccessToken({ pin: accept.txCode });
for (const credential of data.credentials) {
const credentialResponse = await data.client.acquireCredentials({
await client.acquireAccessToken({ pin: accept.txCode });

const metadata =
(await client.retrieveServerMetadata()) as EndpointMetadataResultV1_0_13;

const credentials = this.getCredentials(client, metadata);

for (const credential of credentials) {
const credentialResponse = await client.acquireCredentials({
credentialTypes: (credential as CredentialSupportedSdJwtVc).vct,
proofCallbacks,
alg: Alg.ES256,
Expand All @@ -135,7 +142,7 @@ export class Oid4vciService {
value: credentialResponse.credential as string,
id: sdjwtvc.jwt.payload.jti as string,
metaData: credential,
issuer: data.issuer,
issuer: metadata.credentialIssuerMetadata.display[0],
},
user
);
Expand All @@ -151,7 +158,7 @@ export class Oid4vciService {
} */

//remove the old session
this.sessions.delete(accept.id);
await this.sessionRepository.delete({ id: accept.id });
return { id: credentialEntry.id };
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { IsString } from 'class-validator';

export class Oid4vpParseRequest {
@IsString()
url: string;

@IsBoolean()
@IsOptional()
noSession?: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { PresentationDefinitionWithLocation } from '@sphereon/did-auth-siop';
import { PrimaryColumn, Column, CreateDateColumn, Entity } from 'typeorm';

@Entity()
export class VPSessionEntity {
@PrimaryColumn({ type: 'uuid' })
id: string;

@Column()
user: string;

@Column()
requestObjectJwt: string;

@Column({ type: 'json' })
pds: PresentationDefinitionWithLocation[];

@CreateDateColumn()
createdAt: Date;
}
Loading